Compare commits

..

10 Commits

@ -0,0 +1,2 @@
tests/
.venv/

1
.gitignore vendored

@ -1,3 +1,4 @@
env/
__pycache__/
venv/
tests/

87
Jenkinsfile vendored

@ -57,6 +57,93 @@ pipeline {
}
}
// stage('Security: Verify Dependencies') {
// steps {
// script {
// sh """#!/bin/bash
// set -e
// WORKSPACE_DIR="\$(pwd)"
// echo "Working directory: \${WORKSPACE_DIR}"
// echo "1. Checking Python dependencies..."
// if [ -f "\${WORKSPACE_DIR}/requirements.txt" ]; then
// echo " - Found requirements.txt"
// echo ""
// echo "2. Running security checks inside Python container..."
// # Buat container, copy file, jalankan check
// CONTAINER_ID=\$(docker create python:3.11-slim bash -c '
// set -e
// echo "Installing security tools..."
// pip install --upgrade pip setuptools wheel > /dev/null 2>&1
// pip install pip-audit safety > /dev/null 2>&1
// echo ""
// echo "Installing dependencies from requirements.txt..."
// pip install -r /tmp/requirements.txt
// echo ""
// echo "Running pip-audit for known vulnerabilities..."
// pip-audit --desc || {
// echo "Security vulnerabilities detected"
// exit 1
// }
// echo ""
// echo "Running safety check..."
// safety check || {
// echo "Safety check found issues"
// exit 1
// }
// echo ""
// echo "Checking for outdated packages..."
// pip list --outdated || echo "All packages are up to date"
// echo ""
// echo "Verifying package integrity..."
// pip check || {
// echo "Dependency conflicts detected"
// exit 1
// }
// echo "All Python dependencies verified successfully"
// ')
// echo " - Container ID: \${CONTAINER_ID}"
// # Copy requirements.txt langsung ke container (tanpa volume mount)
// docker cp "\${WORKSPACE_DIR}/requirements.txt" "\${CONTAINER_ID}:/tmp/requirements.txt"
// # Copy Pipfile jika ada
// if [ -f "\${WORKSPACE_DIR}/Pipfile.lock" ]; then
// docker cp "\${WORKSPACE_DIR}/Pipfile.lock" "\${CONTAINER_ID}:/tmp/Pipfile.lock"
// fi
// # Jalankan container dan pastikan cleanup
// docker start --attach "\${CONTAINER_ID}" || {
// EXIT_CODE=\$?
// docker rm "\${CONTAINER_ID}" > /dev/null 2>&1 || true
// exit \${EXIT_CODE}
// }
// docker rm "\${CONTAINER_ID}" > /dev/null 2>&1 || true
// echo "✓ All Python dependencies are verified and up to date"
// else
// echo " - requirements.txt not found, skipping dependency check"
// echo " Directory contents:"
// ls -la "\${WORKSPACE_DIR}/"
// fi
// """
// }
// }
// }
stage('Setup Vault SSH Tunnel') {
steps {
sshagent(credentials: ['vault-server-ssh-key']) {

@ -0,0 +1,8 @@
{"timestamp": "2026-05-26T15:34:23.850085+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:23.850303+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:23.850685+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:26.442755+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:26.442988+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:26.443255+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:43.799353+07:00", "service_name": "Be-OptimumOH", "level": "WARNING", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:43.799965+07:00", "service_name": "Be-OptimumOH", "level": "WARNING", "method": "POST", "path": "/overhaul-gantt/spreadsheet", "status_code": 403, "duration_ms": 79.18, "request_id": "becb4296-58dd-11f1-b957-00155de829e0", "user_id": "2d26235f-a61c-4541-b817-be65b780e3eb", "user_ip": "127.0.0.1", "error_id": "0dbb51ac-0e3b-43a7-80ce-a1d8edf1945c", "result": "Request failed", "error_type": "Forbidden", "error_message": "CSRF token missing in header"}

@ -1,7 +1,5 @@
import uvicorn
from src.config import HOST, PORT, ENV
if __name__ == "__main__":
is_dev = str(ENV).lower() == "development"
uvicorn.run("src.main:app", host=HOST, port=PORT, reload=is_dev)
if __name__ == '__main__':
is_dev = str(ENV).lower() == 'development'
uvicorn.run('src.main:app', host=HOST, port=PORT, reload=is_dev)

@ -1,183 +1,45 @@
from typing import List, Optional
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from src.auth.service import JWTBearer
from src.calculation_budget_constrains.router import \
router as calculation_budget_constraint
from src.calculation_target_reliability.router import \
router as calculation_target_reliability
from src.calculation_time_constrains.router import \
router as calculation_time_constrains_router, get_calculation
# from src.job.router import router as job_router
from src.calculation_budget_constrains.router import router as calculation_budget_constraint
from src.calculation_target_reliability.router import router as calculation_target_reliability
from src.calculation_time_constrains.router import router as calculation_time_constrains_router, get_calculation
from src.overhaul.router import router as overhaul_router
from src.standard_scope.router import router as standard_scope_router
from src.overhaul_activity.router import router as overhaul_activity_router
from src.workscope_group.router import router as workscope_group_router
from src.equipment_workscope_group.router import router as equipment_workscope_group_router
# from src.overhaul_job.router import router as job_overhaul_router
# from src.overhaul_scope.router import router as scope_router
# from src.scope_equipment.router import router as scope_equipment_router
# from src.scope_equipment_job.router import router as scope_equipment_job_router
# from src.overhaul_schedule.router import router as overhaul_schedule_router
from src.overhaul_gantt.router import router as gantt_router
from src.sparepart.router import router as sparepart_router
from src.equipment_sparepart.router import router as equipment_sparepart_router
# from src.overhaul_scope.router import router as scope_router
# from src.scope_equipment.router import router as scope_equipment_router
# from src.overhaul.router import router as overhaul_router
# from src.overhaul_history.router import router as overhaul_history_router
# from src.overhaul_activity.router import router as scope_equipment_activity_router
from src.overhaul_scope.router import router as ovehaul_schedule_router
# from src.scope_equipment_part.router import router as scope_equipment_part_router
# from src.calculation_target_reliability.router import router as calculation_target_reliability
#
# from src.master_activity.router import router as activity_router
class ErrorMessage(BaseModel):
msg: str
class ErrorResponse(BaseModel):
detail: Optional[List[ErrorMessage]]
api_router = APIRouter(default_response_class=JSONResponse, responses={400: {'model': ErrorResponse}, 401: {'model': ErrorResponse}, 403: {'model': ErrorResponse}, 404: {'model': ErrorResponse}, 500: {'model': ErrorResponse}})
api_router = APIRouter(
default_response_class=JSONResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
403: {"model": ErrorResponse},
404: {"model": ErrorResponse},
500: {"model": ErrorResponse},
},
)
@api_router.get("/healthcheck", include_in_schema=False)
@api_router.get('/healthcheck', include_in_schema=False)
def healthcheck():
return {"status": "ok"}
authenticated_api_router = APIRouter(
dependencies=[Depends(JWTBearer())],
)
# overhaul data
authenticated_api_router.include_router(
overhaul_router, prefix="/overhauls", tags=["overhaul"]
)
# authenticated_api_router.include_router(job_router, prefix="/jobs", tags=["job"])
# # # Overhaul session data
# authenticated_api_router.include_router(
# scope_router, prefix="/overhaul-session", tags=["overhaul-session"]
# )
authenticated_api_router.include_router(
standard_scope_router, prefix="/scope-equipments", tags=["scope_equipment"]
)
authenticated_api_router.include_router(
overhaul_activity_router, prefix="/overhaul-activity", tags=["activity"]
)
authenticated_api_router.include_router(
workscope_group_router, prefix="/workscopes", tags=["workscope_groups"]
)
authenticated_api_router.include_router(
sparepart_router, prefix="/spareparts", tags=["sparepart"]
)
authenticated_api_router.include_router(
equipment_workscope_group_router,
prefix="/equipment-workscopes",
tags=["equipment_workscope_groups"],
)
authenticated_api_router.include_router(
equipment_sparepart_router,
prefix="/equipment-spareparts",
tags=["equipment_workscope_sparepart_router"]
)
# authenticated_api_router.include_router(
# scope_equipment_job_router,
# prefix="/scope-equipment-jobs",
# tags=["scope_equipment", "job"],
# )
# authenticated_api_router.include_router(
# overhaul_schedule_router,
# prefix="/overhaul-schedules",
# tags=["overhaul_schedule"],
# )
# authenticated_api_router.include_router(
# job_overhaul_router, prefix="/overhaul-jobs", tags=["job", "overhaul"]
# )
authenticated_api_router.include_router(
gantt_router, prefix="/overhaul-gantt", tags=["gantt"]
)
# authenticated_api_router.include_router(
# overhaul_history_router, prefix="/overhaul-history", tags=["overhaul_history"]
# )
# authenticated_api_router.include_router(
# scope_equipment_activity_router, prefix="/equipment-activities", tags=["scope_equipment_activities"]
# )
# authenticated_api_router.include_router(
# activity_router, prefix="/activities", tags=["activities"]
# )
# authenticated_api_router.include_router(
# scope_equipment_part_router, prefix="/equipment-parts", tags=["scope_equipment_parts"]
# )
authenticated_api_router.include_router(
ovehaul_schedule_router, prefix="/overhaul-schedules", tags=["overhaul_schedules"]
)
# calculation
calculation_router = APIRouter(prefix="/calculation", tags=["calculations"])
# Time constrains
calculation_router.include_router(
calculation_time_constrains_router,
prefix="/time-constraint",
tags=["calculation", "time_constraint"],
)
# Target reliability
calculation_router.include_router(
calculation_target_reliability,
prefix="/target-reliability",
tags=["calculation", "target_reliability"],
)
# # Budget Constrain
calculation_router.include_router(
calculation_budget_constraint,
prefix="/budget-constraint",
tags=["calculation", "budget_constraint"],
)
return {'status': 'ok'}
authenticated_api_router = APIRouter(dependencies=[Depends(JWTBearer())])
authenticated_api_router.include_router(overhaul_router, prefix='/overhauls', tags=['overhaul'])
authenticated_api_router.include_router(standard_scope_router, prefix='/scope-equipments', tags=['scope_equipment'])
authenticated_api_router.include_router(overhaul_activity_router, prefix='/overhaul-activity', tags=['activity'])
authenticated_api_router.include_router(workscope_group_router, prefix='/workscopes', tags=['workscope_groups'])
authenticated_api_router.include_router(sparepart_router, prefix='/spareparts', tags=['sparepart'])
authenticated_api_router.include_router(equipment_workscope_group_router, prefix='/equipment-workscopes', tags=['equipment_workscope_groups'])
authenticated_api_router.include_router(equipment_sparepart_router, prefix='/equipment-spareparts', tags=['equipment_workscope_sparepart_router'])
authenticated_api_router.include_router(gantt_router, prefix='/overhaul-gantt', tags=['gantt'])
authenticated_api_router.include_router(ovehaul_schedule_router, prefix='/overhaul-schedules', tags=['overhaul_schedules'])
calculation_router = APIRouter(prefix='/calculation', tags=['calculations'])
calculation_router.include_router(calculation_time_constrains_router, prefix='/time-constraint', tags=['calculation', 'time_constraint'])
calculation_router.include_router(calculation_target_reliability, prefix='/target-reliability', tags=['calculation', 'target_reliability'])
calculation_router.include_router(calculation_budget_constraint, prefix='/budget-constraint', tags=['calculation', 'budget_constraint'])
authenticated_api_router.include_router(calculation_router)
authenticated_api_router.include_router(
get_calculation,
prefix="/calculation/time-constraint",
tags=["calculation", "time_constraint"],
)
authenticated_api_router.include_router(get_calculation, prefix='/calculation/time-constraint', tags=['calculation', 'time_constraint'])
api_router.include_router(authenticated_api_router)

@ -4,129 +4,61 @@ import json
import datetime
import os
import sys
try:
from zoneinfo import ZoneInfo
except ImportError:
from backports.zoneinfo import ZoneInfo # type: ignore[no-redef]
from backports.zoneinfo import ZoneInfo
from src.config import LOG_LEVEL, SERVICE_NAME
from src.enums import OptimumOHEnum
_TZ_LOCAL = ZoneInfo("Asia/Jakarta")
# ANSI Color Codes
RESET = "\033[0m"
COLORS = {
"DEBUG": "\033[36m", # Cyan
"INFO": "\033[32m", # Green
"WARNING": "\033[33m", # Yellow
"WARN": "\033[33m", # Yellow
"ERROR": "\033[31m", # Red
"CRITICAL": "\033[1;31m", # Bold Red
}
# Fields in the log record injected by the logging middleware or extra= kwargs
_REQUEST_FIELDS = frozenset({
"method", "path", "status_code", "duration_ms",
"request_id", "user_id", "user_ip",
"error_id", "result", "error_type", "error_message",
})
_TZ_LOCAL = ZoneInfo('Asia/Jakarta')
RESET = '\x1b[0m'
COLORS = {'DEBUG': '\x1b[36m', 'INFO': '\x1b[32m', 'WARNING': '\x1b[33m', 'WARN': '\x1b[33m', 'ERROR': '\x1b[31m', 'CRITICAL': '\x1b[1;31m'}
_REQUEST_FIELDS = frozenset({'method', 'path', 'status_code', 'duration_ms', 'request_id', 'user_id', 'user_ip', 'error_id', 'result', 'error_type', 'error_message'})
class LogLevels(OptimumOHEnum):
info = "INFO"
warn = "WARN"
error = "ERROR"
debug = "DEBUG"
info = 'INFO'
warn = 'WARN'
error = 'ERROR'
debug = 'DEBUG'
class JSONFormatter(logging.Formatter):
"""
Outputs exactly 14 fixed fields per log line in JSON format.
Fields (always present, null when not applicable):
timestamp, service_name, level, method, path, status_code,
duration_ms, request_id, user_id, user_ip, error_id,
result, error_type, error_message
"""
def format(self, record: logging.LogRecord) -> str:
dt = datetime.datetime.fromtimestamp(record.created, tz=_TZ_LOCAL)
timestamp = dt.isoformat(timespec="microseconds")
status_code = getattr(record, "status_code", None)
result = getattr(record, "result", None)
timestamp = dt.isoformat(timespec='microseconds')
status_code = getattr(record, 'status_code', None)
result = getattr(record, 'result', None)
if result is None and status_code is not None:
result = "Request completed" if int(status_code) < 400 else "Request failed"
log_record = {
"timestamp": timestamp,
"service_name": SERVICE_NAME,
"level": record.levelname,
"method": getattr(record, "method", None),
"path": getattr(record, "path", None),
"status_code": status_code,
"duration_ms": getattr(record, "duration_ms", None),
"request_id": getattr(record, "request_id", None),
"user_id": getattr(record, "user_id", None),
"user_ip": getattr(record, "user_ip", None),
"error_id": getattr(record, "error_id", None),
"result": result,
"error_type": getattr(record, "error_type", None),
"error_message": getattr(record, "error_message", None),
}
result = 'Request completed' if int(status_code) < 400 else 'Request failed'
log_record = {'timestamp': timestamp, 'service_name': SERVICE_NAME, 'level': record.levelname, 'method': getattr(record, 'method', None), 'path': getattr(record, 'path', None), 'status_code': status_code, 'duration_ms': getattr(record, 'duration_ms', None), 'request_id': getattr(record, 'request_id', None), 'user_id': getattr(record, 'user_id', None), 'user_ip': getattr(record, 'user_ip', None), 'error_id': getattr(record, 'error_id', None), 'result': result, 'error_type': getattr(record, 'error_type', None), 'error_message': getattr(record, 'error_message', None)}
log_json = json.dumps(log_record, default=str)
if sys.stdout.isatty():
color = COLORS.get(record.levelname, "")
return f"{color}{log_json}{RESET}"
color = COLORS.get(record.levelname, '')
return f'{color}{log_json}{RESET}'
return log_json
def configure_logging() -> None:
log_level = str(LOG_LEVEL).upper()
if log_level not in list(LogLevels):
log_level = LogLevels.error
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
if root_logger.hasHandlers():
root_logger.handlers.clear()
formatter = JSONFormatter()
# stdout handler
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler)
# rotating file handler — logs/app.log 10 MB × 5 backups
os.makedirs("logs", exist_ok=True)
file_handler = logging.handlers.RotatingFileHandler(
"logs/app.log",
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
os.makedirs('logs', exist_ok=True)
file_handler = logging.handlers.RotatingFileHandler('logs/app.log', maxBytes=10 * 1024 * 1024, backupCount=5, encoding='utf-8')
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
# Reconfigure uvicorn loggers to propagate through our formatter
for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"]:
for logger_name in ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'fastapi']:
uvicorn_logger = logging.getLogger(logger_name)
uvicorn_logger.handlers = []
uvicorn_logger.propagate = True
# Silence chatty loggers — we emit one structured log per request ourselves
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
slowapi_logger = logging.getLogger("slowapi")
logging.getLogger('uvicorn.access').setLevel(logging.WARNING)
slowapi_logger = logging.getLogger('slowapi')
slowapi_logger.setLevel(logging.ERROR)
slowapi_logger.propagate = False
logging.getLogger("slack_sdk.web.base_client").setLevel(logging.CRITICAL)
logging.getLogger('slack_sdk.web.base_client').setLevel(logging.CRITICAL)

@ -2,9 +2,8 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any, List, Union
from fastapi import Request, HTTPException, status
Allow: str = "allow"
Deny: str = "deny"
Allow: str = 'allow'
Deny: str = 'deny'
@dataclass(frozen=True)
class Principal:
@ -12,147 +11,95 @@ class Principal:
value: str
def __repr__(self) -> str:
return f"{self.key}:{self.value}"
return f'{self.key}:{self.value}'
def __str__(self) -> str:
return self.__repr__()
@dataclass(frozen=True)
class SystemPrincipal(Principal):
def __init__(self, value: str):
super().__init__(key="system", value=value)
super().__init__(key='system', value=value)
@dataclass(frozen=True)
class RolePrincipal(Principal):
def __init__(self, value: str):
super().__init__(key="role", value=value)
Everyone = SystemPrincipal(value="everyone")
Authenticated = SystemPrincipal(value="authenticated")
def __init__(self, value: str):
super().__init__(key='role', value=value)
Everyone = SystemPrincipal(value='everyone')
Authenticated = SystemPrincipal(value='authenticated')
class OHPermission(Enum):
CREATE = "create"
READ = "read"
EDIT = "edit"
DELETE = "delete"
CREATE = 'create'
READ = 'read'
EDIT = 'edit'
DELETE = 'delete'
class AccessControl:
def __init__(self, permission_exception: Any=None):
self.permission_exception = permission_exception or HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied"
)
self.permission_exception = permission_exception or HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Permission denied')
def _acl(self, resource):
acl = getattr(resource, "__acl__", [])
acl = getattr(resource, '__acl__', [])
if callable(acl):
return acl()
return acl
def has_permission(
self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any
) -> bool:
def has_permission(self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any) -> bool:
if not isinstance(resource, list):
resource = [resource]
permits = []
for res in resource:
granted = False
acl = self._acl(res)
for action, principal, permissions in acl:
# Check if any of the required permissions are in the allowed permissions for this principal
is_permitted = any(p in permissions for p in required_permissions)
if (action == Allow and is_permitted) and (
principal in principals or principal == Everyone
):
is_permitted = any((p in permissions for p in required_permissions))
if (action == Allow and is_permitted) and (principal in principals or principal == Everyone):
granted = True
break
elif (action == Deny and is_permitted) and (
principal in principals or principal == Everyone
):
if (action == Deny and is_permitted) and (principal in principals or principal == Everyone):
granted = False
break
permits.append(granted)
return all(permits)
def assert_access(
self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any
):
def assert_access(self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any):
if not self.has_permission(principals, required_permissions, resource):
raise self.permission_exception
# Roles allowed to access the backend (whitelist).
# Management is intentionally excluded.
ALLOWED_ROLES = {"Admin", "Super Admin", "Engineer", "Application Administrator"}
ALLOWED_ROLES = {'Admin', 'Super Admin', 'Engineer', 'Application Administrator'}
def require_any_role(*allowed_roles: str):
"""
FastAPI dependency whitelist-based role check.
Raises 403 if the authenticated user's role is not in the allowed set.
Comparison is case-insensitive.
Usage (router-level):
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
"""
normalized = {r.lower() for r in allowed_roles}
async def dependency(request: Request):
user = getattr(request.state, "user", None)
user = getattr(request.state, 'user', None)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
)
user_role = user.get("role") if isinstance(user, dict) else getattr(user, "role", None)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
user_role = user.get('role') if isinstance(user, dict) else getattr(user, 'role', None)
if not user_role or user_role.lower() not in normalized:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your role does not have permission to access this resource.",
)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Your role does not have permission to access this resource.')
return True
return dependency
def required_permission(permissions: Union[OHPermission, List[OHPermission]], resources: Any):
"""
FastAPI dependency for role-based access control.
"""
if isinstance(permissions, OHPermission):
permissions = [permissions]
async def dependency(request: Request):
user = getattr(request.state, "user", None)
user = getattr(request.state, 'user', None)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
user_role = None
if isinstance(user, dict):
user_role = user.get("role")
user_role = user.get('role')
else:
user_role = getattr(user, "role", None)
user_role = getattr(user, 'role', None)
if not user_role:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="No user role found"
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='No user role found')
user_principals = [Authenticated, RolePrincipal(user_role)]
ac = AccessControl()
ac.assert_access(user_principals, permissions, resources)
return True
return dependency

@ -1,10 +1,7 @@
from typing import Optional
from pydantic import BaseModel
class UserBase(BaseModel):
# Some users from auth service may not have a display name.
name: Optional[str] = None
role: str
user_id: str

@ -1,247 +1,135 @@
# app/auth/auth_bearer.py
import json
from typing import Annotated, Optional
from typing import Annotated
import requests
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.sql.expression import false
import src.config as config
from .model import UserBase
from .util import extract_template
class JWTBearer(HTTPBearer):
def __init__(self, auto_error: bool=True):
# Always pass auto_error=False so that HTTPBearer returns None (instead of
# raising HTTP 403) when the Authorization header is absent. We raise 401
# ourselves in the else-branch below, which is the correct status for a
# missing / unauthenticated credential.
super(JWTBearer, self).__init__(auto_error=False)
async def __call__(self, request: Request):
credentials: HTTPAuthorizationCredentials = await super(
JWTBearer, self
).__call__(request)
credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request)
if credentials:
if not credentials.scheme == "Bearer":
raise HTTPException(
status_code=401, detail="Invalid authentication scheme."
)
if not credentials.scheme == 'Bearer':
raise HTTPException(status_code=401, detail='Invalid authentication scheme.')
method = request.method
if method == "OPTIONS":
return
if method == 'OPTIONS':
return None
path = extract_template(request.url.path, request.path_params)
endpoint = f"/optimumoh{path}"
endpoint = f'/optimumoh{path}'
user_info, message = self.verify_jwt(credentials.credentials, method, endpoint)
if not user_info:
if isinstance(message, dict):
message = message.get("message") or message.get("detail") or "Invalid token or expired token."
message = message.get('message') or message.get('detail') or 'Invalid token or expired token.'
else:
message = str(message) if message else "Invalid token or expired token."
raise HTTPException(
status_code=401, detail=message
)
message = str(message) if message else 'Invalid token or expired token.'
raise HTTPException(status_code=401, detail=message)
request.state.user = message
from src.context import set_user_id, set_username, set_role
if hasattr(message, "user_id"):
if hasattr(message, 'user_id'):
set_user_id(str(message.user_id))
username = getattr(message, "username", None) or getattr(message, "name", None)
username = getattr(message, 'username', None) or getattr(message, 'name', None)
if username:
set_username(username)
if hasattr(message, "role"):
if hasattr(message, 'role'):
set_role(message.role)
return message
else:
raise HTTPException(status_code=401, detail="Invalid authorization code.")
raise HTTPException(status_code=401, detail='Invalid authorization code.')
def verify_jwt(self, jwtoken: str, method: str, endpoint: str):
try:
url_to_verify = f"{config.AUTH_SERVICE_API}/verify-token"
response = requests.get(
url_to_verify,
headers={"Authorization": f"Bearer {jwtoken}"},
)
url_to_verify = f'{config.AUTH_SERVICE_API}/verify-token'
response = requests.get(url_to_verify, headers={'Authorization': f'Bearer {jwtoken}'})
if not response.ok:
return False, response.json()
return (False, response.json())
user_data = response.json()
return True, UserBase(**user_data["data"])
return (True, UserBase(**user_data['data']))
except Exception as e:
print(f"Token verification error: {str(e)}")
return False, str(e)
print(f'Token verification error: {str(e)}')
return (False, str(e))
# Create dependency to get current user from request state
async def get_current_user(request: Request) -> UserBase:
return request.state.user
async def get_token(request: Request):
token = request.headers.get("Authorization")
token = request.headers.get('Authorization')
if token:
return token.replace("Bearer ", "") # Menghapus prefix "Bearer "
else:
return request.cookies.get("access_token") # Fallback ke cookie
return "" # Mengembalikan token atau None jika tidak ada
return token.replace('Bearer ', '')
return request.cookies.get('access_token')
return ''
async def internal_key(request: Request):
token = request.headers.get("Authorization")
token = request.headers.get('Authorization')
if not token:
api_key = request.headers.get("X-Internal-Key")
api_key = request.headers.get('X-Internal-Key')
if api_key != config.API_KEY:
raise HTTPException(
status_code=401, detail="Invalid Key."
)
raise HTTPException(status_code=401, detail='Invalid Key.')
try:
headers = {
'Content-Type': 'application/json'
}
response = requests.post(
f"{config.AUTH_SERVICE_API}/sign-in",
headers=headers,
data=json.dumps({
"username": "ohuser",
"password": "123456789"
})
)
headers = {'Content-Type': 'application/json'}
response = requests.post(f'{config.AUTH_SERVICE_API}/sign-in', headers=headers, data=json.dumps({'username': 'ohuser', 'password': '123456789'}))
if not response.ok:
print(str(response.json()))
raise Exception("error auth")
raise Exception('error auth')
user_data = response.json()
return user_data['data']['access_token']
except Exception as e:
raise Exception(str(e))
else:
try:
verify_url = f"{config.AUTH_SERVICE_API}/verify-token"
response = requests.get(
verify_url,
headers={"Authorization": f"{token}"},
)
verify_url = f'{config.AUTH_SERVICE_API}/verify-token'
response = requests.get(verify_url, headers={'Authorization': f'{token}'})
if not response.ok:
raise HTTPException(
status_code=401, detail="Invalid token."
)
return token.split(" ")[1]
raise HTTPException(status_code=401, detail='Invalid token.')
return token.split(' ')[1]
except Exception as e:
print(f"Token verification error: {str(e)}")
return False, str(e)
print(f'Token verification error: {str(e)}')
return (False, str(e))
import asyncio
import logging
from typing import Dict, Any
import src.config as config
log = logging.getLogger(__name__)
AUTH_NOTIFY_ENDPOINT = f'{config.AUTH_SERVICE_API}/admin/notify-limit'
AUTH_NOTIFY_ENDPOINT = f"{config.AUTH_SERVICE_API}/admin/notify-limit"
async def notify_admin_on_rate_limit(
endpoint_name: str,
ip_address: str,
request: Request,
method: str = "POST",
cooldown: int = 900,
timeout: int = 5
) -> Dict[str, Any]:
"""
Kirim notifikasi ke admin via be-auth service ketika rate limit terlampaui.
Async version - gunakan di async context.
"""
payload = {
"endpoint_name": f"oh/{endpoint_name}".replace("//", ""),
"ip_address": ip_address,
"method": method,
"cooldown": cooldown,
}
token = request.headers.get("Authorization")
headers = {"Authorization": token} if token else {}
async def notify_admin_on_rate_limit(endpoint_name: str, ip_address: str, request: Request, method: str='POST', cooldown: int=900, timeout: int=5) -> Dict[str, Any]:
payload = {'endpoint_name': f'oh/{endpoint_name}'.replace('//', ''), 'ip_address': ip_address, 'method': method, 'cooldown': cooldown}
token = request.headers.get('Authorization')
headers = {'Authorization': token} if token else {}
try:
response = await asyncio.to_thread(
requests.post, AUTH_NOTIFY_ENDPOINT,
json=payload, headers=headers, timeout=timeout
)
response = await asyncio.to_thread(requests.post, AUTH_NOTIFY_ENDPOINT, json=payload, headers=headers, timeout=timeout)
response.raise_for_status()
result = response.json()
log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}')
return result
except Exception as e:
log.error(f"Error notifying admin: {str(e)}")
return {"status": False, "message": str(e), "data": payload}
def notify_admin_on_rate_limit_sync(
endpoint_name: str,
ip_address: str,
request: Request,
method: str = "POST",
cooldown: int = 900,
timeout: int = 5
) -> Dict[str, Any]:
"""
Kirim notifikasi ke admin via be-auth service.
Sync version - gunakan di exception handler atau sync context.
RECOMMENDED untuk use case ini.
"""
payload = {
"endpoint_name": f"oh/{endpoint_name}".replace("//", "/"),
"ip_address": ip_address,
"method": method,
"cooldown": cooldown,
}
token = request.headers.get("Authorization")
headers = {"Authorization": token} if token else {}
log.error(f'Error notifying admin: {str(e)}')
return {'status': False, 'message': str(e), 'data': payload}
def notify_admin_on_rate_limit_sync(endpoint_name: str, ip_address: str, request: Request, method: str='POST', cooldown: int=900, timeout: int=5) -> Dict[str, Any]:
payload = {'endpoint_name': f'oh/{endpoint_name}'.replace('//', '/'), 'ip_address': ip_address, 'method': method, 'cooldown': cooldown}
token = request.headers.get('Authorization')
headers = {'Authorization': token} if token else {}
try:
response = requests.post(AUTH_NOTIFY_ENDPOINT, json=payload, headers=headers, timeout=timeout)
response.raise_for_status()
result = response.json()
log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}')
return result
except Exception as e:
log.error(f"Error notifying admin: {str(e)}")
return {"status": False, "message": str(e), "data": payload}
log.error(f'Error notifying admin: {str(e)}')
return {'status': False, 'message': str(e), 'data': payload}
async def admin_required(request: Request):
user = await get_current_user(request)
if user.role != 'Admin' or user.role != 'Superadmin':
raise HTTPException(status_code=403, detail='Invalid authorization code.')
return user
Admin = Annotated[UserBase, Depends(admin_required)]
CurrentUser = Annotated[UserBase, Depends(get_current_user)]
Token = Annotated[str, Depends(get_token)]
InternalKey = Annotated[str, Depends(internal_key)]

@ -1,9 +1,6 @@
def extract_template(path_string, value_dict):
template = path_string
# Replace each value in the dict with its corresponding key placeholder
for key, value in value_dict.items():
if str(value) in template:
template = template.replace(str(value), f'<{key}>')
return template

@ -1,46 +1,21 @@
from typing import Annotated, Dict, List, Optional
from fastapi import APIRouter, HTTPException, status
from typing import Annotated, Dict
from fastapi import APIRouter
from fastapi.params import Query
from src.auth.service import Token
from src.calculation_budget_constrains.schema import BudgetContraintQuery
from src.calculation_target_reliability.service import get_simulation_results
from src.config import TC_RBD_ID
from src.database.core import CollectorDbSession, DbSession
from src.models import StandardResponse
from .service import get_all_budget_constrains
from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES
from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("/{session_id}", response_model=StandardResponse[Dict], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_target_reliability(
db_session: DbSession,
token: Token,
session_id: str,
collector_db: CollectorDbSession,
params: Annotated[BudgetContraintQuery, Query()],
):
"""Get all scope pagination."""
@router.get('/{session_id}', response_model=StandardResponse[Dict], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_target_reliability(db_session: DbSession, token: Token, session_id: str, collector_db: CollectorDbSession, params: Annotated[BudgetContraintQuery, Query()]):
cost_threshold = params.cost_threshold
results = await get_simulation_results(
simulation_id = TC_RBD_ID,
token=token
)
results, consequence = await get_all_budget_constrains(
db_session=db_session, session_id=session_id, cost_threshold=cost_threshold, simulation_result=results, collector_db=collector_db
)
return StandardResponse(
data={
"results": results,
"consequence": consequence
},
message="Data retrieved successfully",
)
results = await get_simulation_results(simulation_id=TC_RBD_ID, token=token)
results, consequence = await get_all_budget_constrains(db_session=db_session, session_id=session_id, cost_threshold=cost_threshold, simulation_result=results, collector_db=collector_db)
return StandardResponse(data={'results': results, 'consequence': consequence}, message='Data retrieved successfully')

@ -1,29 +1,18 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from typing import Any, Dict, List
from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination
from src.models import DefultBase
class OverhaulBase(BaseModel):
pass
class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description="List of critical parts")
criticalParts: List[str] = Field(..., description='List of critical parts')
class OverhaulSchedules(OverhaulBase):
schedules: List[Dict[str, Any]] = Field(..., description="List of schedules")
schedules: List[Dict[str, Any]] = Field(..., description='List of schedules')
class OverhaulSystemComponents(OverhaulBase):
systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
systemComponents: Dict[str, Any] = Field(..., description='List of system components')
class OverhaulRead(OverhaulBase):
overview: Dict[str, Any]
@ -31,44 +20,5 @@ class OverhaulRead(OverhaulBase):
schedules: List[Dict[str, Any]]
systemComponents: Dict[str, Any]
class BudgetContraintQuery(DefultBase):
cost_threshold: float = 100
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }
cost_threshold: float = Field(100, ge=0, le=1000000000000000)

@ -1,178 +1,108 @@
from collections import defaultdict
import random
from typing import Optional
import math
from typing import Optional, List, Dict, Tuple
from uuid import UUID
from sqlalchemy import Delete, Select
from src.auth.service import CurrentUser
from src.contribution_util import calculate_contribution_accurate
from src.database.core import CollectorDbSession, DbSession
# from src.scope_equipment.model import ScopeEquipment
# from src.scope_equipment.service import get_by_scope_name
from src.overhaul_activity.service import get_all_by_session_id, get_standard_scope_by_session_id
# async def get_all_budget_constrains(
# *, db_session: DbSession, session_id: str, cost_threshold: float = 100000000
# ):
# At the module level, add this dictionary to store persistent EAF values
from src.overhaul_activity.service import get_standard_scope_by_session_id
from collections import defaultdict
from uuid import UUID
from typing import List, Dict, Tuple
from src.database.core import CollectorDbSession, DbSession
from src.overhaul_activity.service import get_standard_scope_by_session_id
from src.contribution_util import calculate_contribution_accurate
async def get_all_budget_constrains(
*,
db_session: DbSession,
collector_db: CollectorDbSession,
session_id: UUID,
simulation_result: Dict,
cost_threshold: float = 100_000_000,
use_optimal: bool = True, # default = optimal (knapsack)
) -> Tuple[List[Dict], List[Dict]]:
"""
Select equipment under budget constraint using contribution + cost efficiency.
Returns (priority_list, consequence_list).
"""
calc_result = simulation_result["calc_result"]
plant_result = simulation_result["plant_result"]
equipments = await get_standard_scope_by_session_id(
db_session=db_session,
overhaul_session_id=session_id,
collector_db=collector_db,
)
async def get_all_budget_constrains(*, db_session: DbSession, collector_db: CollectorDbSession, session_id: UUID, simulation_result: Dict, cost_threshold: float=100000000, use_optimal: bool=True) -> Tuple[List[Dict], List[Dict]]:
calc_result = simulation_result['calc_result']
plant_result = simulation_result['plant_result']
equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=session_id, collector_db=collector_db)
if not equipments:
return [], []
# Flatten results
return ([], [])
eq_results = calc_result if isinstance(calc_result, list) else [calc_result]
# Calculate contribution map (node_name → contribution)
equipments_eaf_contribution = calculate_asset_eaf_contributions(
plant_result=plant_result, eq_results=eq_results
)
# Build base result list
equipments_eaf_contribution = calculate_asset_eaf_contributions(plant_result=plant_result, eq_results=eq_results)
result = []
for equipment in equipments:
contribution = equipments_eaf_contribution.get(equipment.location_tag, 0.0)
total_cost = (equipment.overhaul_cost or 0) + (equipment.service_cost or 0)
result.append(
{
"id": equipment.id,
"location_tag": equipment.location_tag,
"name": equipment.equipment_name,
"total_cost": total_cost,
"eaf_contribution": contribution,
}
)
# Normalize contribution so sum = 1.0
total_contrib = sum(item["eaf_contribution"] for item in result) or 1.0
result.append({'id': equipment.id, 'location_tag': equipment.location_tag, 'name': equipment.equipment_name, 'total_cost': total_cost, 'eaf_contribution': contribution})
total_contrib = sum((item['eaf_contribution'] for item in result)) or 1.0
for item in result:
item["contribution_norm"] = item["eaf_contribution"] / total_contrib
# Calculate efficiency and composite score
item['contribution_norm'] = item['eaf_contribution'] / total_contrib
for item in result:
cost = item["total_cost"] or 1.0
efficiency = item["contribution_norm"] / cost
item["priority_score"] = item["contribution_norm"]
# Choose method
cost = item['total_cost'] or 1.0
item['contribution_norm'] / cost
item['priority_score'] = item['contribution_norm']
if use_optimal:
priority_list, consequence_list = knapsack_selection(result, cost_threshold)
else:
priority_list, consequence_list = greedy_selection(result, cost_threshold)
return priority_list, consequence_list
return (priority_list, consequence_list)
def calculate_asset_eaf_contributions(plant_result, eq_results):
"""
Calculate each asset's negative contribution to plant EAF.
Key assumption: eq_results have aeros_node.node_name matching equipment.location_tag.
"""
results = defaultdict(float)
for asset in eq_results:
node_name = asset.get("aeros_node", {}).get("node_name")
node_name = asset.get('aeros_node', {}).get('node_name')
if node_name:
results[node_name] = asset.get("contribution_factor", 0.0)
results[node_name] = asset.get('contribution_factor', 0.0)
return results
def greedy_selection(equipments: List[dict], budget: float) -> Tuple[List[dict], List[dict]]:
"""Greedy fallback: select items by score until budget is used."""
# Sort by priority_score descending
equipments_sorted = sorted(equipments, key=lambda x: x["priority_score"], reverse=True)
total_cost = 0
selected, excluded = [], []
equipments_sorted = sorted(equipments, key=lambda x: x['priority_score'], reverse=True)
current_cost = 0.0
selected, excluded = ([], [])
for eq in equipments_sorted:
if total_cost + eq["cost"] <= budget:
cost = eq.get('total_cost', 0.0)
if current_cost + cost <= budget:
selected.append(eq)
total_cost += eq["cost"]
current_cost += cost
else:
excluded.append(eq)
return (selected, excluded)
return selected, excluded
def knapsack_selection(equipments: List[dict], budget: float, scale: int = 10_000_000) -> Tuple[List[dict], List[dict]]:
"""
Select equipment optimally within budget using 0/1 knapsack DP.
Uses scaling + 1D DP optimization to avoid MemoryError.
Falls back to greedy if W is too large.
"""
n = len(equipments)
# Scale costs + budget
costs = [int(eq["total_cost"] // scale) for eq in equipments]
values = [eq["priority_score"] for eq in equipments]
def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[float]=None) -> Tuple[List[dict], List[dict]]:
if not equipments:
return ([], [])
eligible_items = []
strictly_excluded = []
for eq in equipments:
if eq['total_cost'] > budget:
strictly_excluded.append(eq)
else:
eligible_items.append(eq)
if not eligible_items:
return ([], strictly_excluded)
n = len(eligible_items)
if scale is None:
target_W = 2000
scale = max(1.0, budget / target_W)
costs = [int(math.ceil(eq['total_cost'] / scale)) for eq in eligible_items]
values = [eq['priority_score'] for eq in eligible_items]
W = int(budget // scale)
# Fallback if W is still too large
if W > 1_000_000:
print("too big")
if W > 1000000:
return greedy_selection(equipments, budget)
# 1D DP array
dp = [0.0] * (W + 1)
keep = [[False] * (W + 1) for _ in range(n)] # track selection choices
keep = [[False] * (W + 1) for _ in range(n)]
for i in range(n):
cost, value = costs[i], values[i]
cost, value = (costs[i], values[i])
for w in range(W, cost - 1, -1):
if dp[w - cost] + value >= dp[w]: # <= FIXED HERE
if dp[w - cost] + value >= dp[w]:
dp[w] = dp[w - cost] + value
keep[i][w] = True
# Backtrack to find selected items
selected, excluded = [], []
selected = []
backtrack_excluded = []
w = W
for i in range(n - 1, -1, -1):
if keep[i][w]:
selected.append(equipments[i])
selected.append(eligible_items[i])
w -= costs[i]
else:
excluded.append(equipments[i])
# Optional: fill leftover budget with zero-priority items
remaining_budget = budget - sum(eq["total_cost"] for eq in selected)
backtrack_excluded.append(eligible_items[i])
excluded = backtrack_excluded + strictly_excluded
current_total_cost = sum((eq['total_cost'] for eq in selected))
remaining_budget = budget - current_total_cost
if remaining_budget > 0:
excluded.sort(key=lambda x: x['priority_score'], reverse=True)
for eq in excluded[:]:
if eq["total_cost"] <= remaining_budget:
if eq['total_cost'] <= remaining_budget:
selected.append(eq)
excluded.remove(eq)
remaining_budget -= eq["total_cost"]
return selected, excluded
remaining_budget -= eq['total_cost']
return (selected, excluded)

@ -1,127 +1,45 @@
import asyncio
from typing import Dict, List, Optional
from typing_extensions import Annotated
from temporalio.client import Client
from fastapi import APIRouter, HTTPException, status
from fastapi.params import Query
from src.calculation_target_reliability.utils import wait_for_workflow
from src.config import TEMPORAL_URL, TR_RBD_ID
from src.database.core import DbSession, CollectorDbSession
from src.auth.service import Token
from src.models import StandardResponse
from .service import run_rbd_simulation, get_simulation_results, identify_worst_eaf_contributors
from .service import get_simulation_results, identify_worst_eaf_contributors
from .schema import OptimizationResult, TargetReliabiltiyQuery
from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends
router = APIRouter()
# @router.get("", response_model=StandardResponse[List[Dict]])
# async def get_target_reliability(
# db_session: DbSession,
# scope_name: Optional[str] = Query(None),
# eaf_threshold: float = Query(100),
# ):
# """Get all scope pagination."""
# results = await get_all_target_reliability(
# db_session=db_session, scope_name=scope_name, eaf_threshold=eaf_threshold
# )
# return StandardResponse(
# data=results,
# message="Data retrieved successfully",
# )
@router.get("", response_model=StandardResponse[OptimizationResult], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_target_reliability(
db_session: DbSession,
token: Token,
collector_db: CollectorDbSession,
params: Annotated[TargetReliabiltiyQuery, Query()],
# oh_session_id: Optional[str] = Query(None),
# eaf_input: float = Query(99.8),
# duration: int = Query(17520),
# simulation_id: Optional[str] = Query(None),
# cut_hours = Query(0)
):
"""Get all scope pagination."""
@router.get('', response_model=StandardResponse[OptimizationResult], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_target_reliability(db_session: DbSession, token: Token, collector_db: CollectorDbSession, params: Annotated[TargetReliabiltiyQuery, Query()]):
oh_session_id = params.oh_session_id
eaf_input = params.eaf_input
duration = params.duration
simulation_id = params.simulation_id
cut_hours = params.cut_hours
if not oh_session_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="oh_session_id is required",
)
# results = await get_eaf_timeline(
# db_session=db_session,
# oh_session_id=oh_session_id,
# eaf_input=eaf_input,
# oh_duration=duration
# )
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='oh_session_id is required')
if duration != 17520:
if not simulation_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Simulation ID is required for non-default duration. Please run simulation first.",
)
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Simulation ID is required for non-default duration. Please run simulation first.')
try:
temporal_client = await Client.connect(TEMPORAL_URL)
handle = temporal_client.get_workflow_handle(f"simulation-{simulation_id}")
handle = temporal_client.get_workflow_handle(f'simulation-{simulation_id}')
desc = await handle.describe()
status_name = desc.status.name
if status_name in ["RUNNING", "CONTINUED_AS_NEW"]:
raise HTTPException(
status_code=status.HTTP_425_TOO_EARLY,
detail="Simulation is still running.",
)
elif status_name != "COMPLETED":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Simulation failed with status: {status_name}",
)
if status_name in ['RUNNING', 'CONTINUED_AS_NEW']:
raise HTTPException(status_code=status.HTTP_425_TOO_EARLY, detail='Simulation is still running.')
if status_name != 'COMPLETED':
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Simulation failed with status: {status_name}')
except HTTPException:
raise
except Exception as e:
# Handle connection errors or invalid workflow IDs
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Simulation not found or error checking status: {str(e)}",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f'Simulation not found or error checking status: {str(e)}')
else:
simulation_id = TR_RBD_ID
results = await get_simulation_results(
simulation_id=simulation_id,
token=token
)
optimize_result = await identify_worst_eaf_contributors(
simulation_result=results,
target_eaf=eaf_input,
db_session=db_session,
oh_session_id=oh_session_id,
collector_db=collector_db,
simulation_id=simulation_id,
duration=duration,
po_duration=1200,
cut_hours=float(cut_hours)
)
return StandardResponse(
data=optimize_result,
message="Data retrieved successfully",
)
results = await get_simulation_results(simulation_id=simulation_id, token=token)
optimize_result = await identify_worst_eaf_contributors(simulation_result=results, target_eaf=eaf_input, db_session=db_session, oh_session_id=oh_session_id, collector_db=collector_db, simulation_id=simulation_id, duration=duration, po_duration=1200, cut_hours=float(cut_hours))
return StandardResponse(data=optimize_result, message='Data retrieved successfully')

@ -1,29 +1,18 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination
from src.models import DefultBase
class OverhaulBase(BaseModel):
pass
class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description="List of critical parts")
criticalParts: List[str] = Field(..., description='List of critical parts')
class OverhaulSchedules(OverhaulBase):
schedules: List[Dict[str, Any]] = Field(..., description="List of schedules")
schedules: List[Dict[str, Any]] = Field(..., description='List of schedules')
class OverhaulSystemComponents(OverhaulBase):
systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
systemComponents: Dict[str, Any] = Field(..., description='List of system components')
class OverhaulRead(OverhaulBase):
overview: Dict[str, Any]
@ -50,7 +39,6 @@ class MaintenanceScenario(OverhaulBase):
plant_level_benefit: float = 0.0
capacity_weight: float
class OptimizationResult(OverhaulBase):
current_plant_eaf: float
target_plant_eaf: float
@ -63,48 +51,9 @@ class OptimizationResult(OverhaulBase):
optimization_success: bool = False
simulation_id: Optional[str] = None
class TargetReliabiltiyQuery(DefultBase):
oh_session_id: Optional[str] = Field(None)
eaf_input: float = Field(99.8)
duration: int = Field(17520)
simulation_id: Optional[str] = Field(None)
cut_hours: int = Field(0)
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,356 +1,142 @@
import math
from typing import Optional, List
from dataclasses import dataclass
from sqlalchemy import Delete, Select
import httpx
from src.auth.service import CurrentUser
from src.config import RBD_SERVICE_API
from src.contribution_util import calculate_contribution, calculate_contribution_accurate
from src.database.core import DbSession, CollectorDbSession
from datetime import datetime, timedelta
import random
from .utils import generate_down_periods
from src.overhaul_scope.service import get as get_overhaul
from bisect import bisect_left
from collections import defaultdict
import asyncio
from .schema import AssetWeight,MaintenanceScenario,OptimizationResult
from .schema import AssetWeight, OptimizationResult
from src.overhaul_activity.service import get_standard_scope_by_session_id
client = httpx.AsyncClient(timeout=300.0)
async def run_rbd_simulation(*, sim_hours: int, token):
sim_data = {
"SimulationName": f"Simulasi TR OH {sim_hours}",
"SchematicName": "- TJB - Unit 3 -",
"SimSeed": 1,
"SimDuration": sim_hours,
"OverhaulInterval": sim_hours - 1201,
"DurationUnit": "UHour",
"SimNumRun": 1,
"IsDefault": False,
"OverhaulDuration": 1200
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
rbd_simulation_url = f"{RBD_SERVICE_API}/aeros/simulation/run"
sim_data = {'SimulationName': f'Simulasi TR OH {sim_hours}', 'SchematicName': '- TJB - Unit 3 -', 'SimSeed': 1, 'SimDuration': sim_hours, 'OverhaulInterval': sim_hours - 1201, 'DurationUnit': 'UHour', 'SimNumRun': 1, 'IsDefault': False, 'OverhaulDuration': 1200}
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
rbd_simulation_url = f'{RBD_SERVICE_API}/aeros/simulation/run'
async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.post(rbd_simulation_url, json=sim_data, headers=headers)
response.raise_for_status()
return response.json()
async def get_simulation_results(*, simulation_id: str, token: str):
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode"
# plot_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/plot/{simulation_id}?nodetype=RegularNode"
calc_plant_result = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant"
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode'
calc_plant_result = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant'
async with httpx.AsyncClient(timeout=300.0) as client:
calc_task = client.get(calc_result_url, headers=headers)
# plot_task = client.get(plot_result_url, headers=headers)
plant_task = client.get(calc_plant_result, headers=headers)
# Run all three requests concurrently
calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status()
# plot_response.raise_for_status()
plant_response.raise_for_status()
calc_data = calc_response.json()["data"]
# plot_data = plot_response.json()["data"]
plant_data = plant_response.json()["data"]
return {
"calc_result": calc_data,
# "plot_result": plot_data,
"plant_result": plant_data
}
calc_data = calc_response.json()['data']
plant_data = plant_response.json()['data']
return {'calc_result': calc_data, 'plant_result': plant_data}
def calculate_asset_eaf_contributions(plant_result, eq_results, standard_scope, eaf_gap, scheduled_outage):
"""
Calculate each asset's contribution to plant EAF with realistic, fair improvement allocation.
The total EAF gap is distributed among assets proportionally to their contribution potential.
Automatically skips equipment with no unplanned downtime (only scheduled outages).
"""
eaf_gap_fraction = eaf_gap / 100.0 if eaf_gap > 1.0 else eaf_gap
total_hours = plant_result.get("total_uptime") + plant_result.get("total_downtime")
total_hours = plant_result.get('total_uptime') + plant_result.get('total_downtime')
plant_operating_fraction = (total_hours - scheduled_outage) / total_hours
REALISTIC_MAX_TECHNICAL = 0.995
REALISTIC_MAX_AVAILABILITY = REALISTIC_MAX_TECHNICAL * plant_operating_fraction
MIN_IMPROVEMENT_PERCENT = 0.0001
min_improvement_fraction = MIN_IMPROVEMENT_PERCENT / 100.0
EPSILON = 0.001 # 1 ms or a fraction of an hour for comparison tolerance
results = []
weighted_assets = []
# Step 1: Collect eligible assets and their weights
for asset in eq_results:
node = asset.get("aeros_node")
node = asset.get('aeros_node')
if not node:
continue
asset_name = node.get("node_name")
num_of_events = asset.get("num_events", 0)
asset_name = node.get('node_name')
num_of_events = asset.get('num_events', 0)
if asset_name not in standard_scope:
continue
contribution_factor = asset.get("contribution_factor", 0.0)
birbaum = asset.get("contribution", 0.0)
current_availability = asset.get("availability", 0.0)
downtime = asset.get("total_downtime", 0.0)
# --- NEW: Skip equipment with no failures and near-maximum availability ---
if (
num_of_events < 2 # no unplanned events
or contribution_factor <= 0
):
# This equipment has nothing to improve realistically
contribution_factor = asset.get('contribution_factor', 0.0)
birbaum = asset.get('contribution', 0.0)
current_availability = asset.get('availability', 0.0)
downtime = asset.get('total_downtime', 0.0)
if num_of_events < 2 or contribution_factor <= 0:
continue
# --- Compute realistic possible improvement ---
if REALISTIC_MAX_AVAILABILITY > current_availability:
max_possible_improvement = REALISTIC_MAX_AVAILABILITY - current_availability
else:
max_possible_improvement = 0.0 # No improvement possible
# Compute weighted importance (Birnbaum × FV)
max_possible_improvement = 0.0
raw_weight = birbaum
weight = math.sqrt(max(raw_weight, 0.0))
weighted_assets.append((asset, weight, 0))
# Step 2: Compute total weight
total_weight = sum(w for _, w, _ in weighted_assets) or 1.0
# Step 3: Distribute improvement proportionally to weight
total_weight = sum((w for _, w, _ in weighted_assets)) or 1.0
for asset, weight, max_possible_improvement in weighted_assets:
node = asset.get("aeros_node")
contribution_factor = asset.get("contribution_factor", 0.0)
birbaum = asset.get("contribution", 0.0)
current_availability = asset.get("availability", 0.0)
downtime = asset.get("total_downtime", 0.0)
node = asset.get('aeros_node')
contribution_factor = asset.get('contribution_factor', 0.0)
birbaum = asset.get('contribution', 0.0)
current_availability = asset.get('availability', 0.0)
downtime = asset.get('total_downtime', 0.0)
required_improvement = eaf_gap_fraction * (weight / total_weight)
required_improvement = min(required_improvement, max_possible_improvement)
required_improvement = max(required_improvement, min_improvement_fraction)
improvement_impact = required_improvement * contribution_factor
efficiency = birbaum / downtime if downtime > 0 else birbaum
contribution = AssetWeight(
node=node,
availability=current_availability,
contribution=contribution_factor,
required_improvement=required_improvement,
improvement_impact=improvement_impact,
num_of_failures=asset.get("num_events", 0),
down_time=downtime,
efficiency=efficiency,
birbaum=birbaum,
)
contribution = AssetWeight(node=node, availability=current_availability, contribution=contribution_factor, required_improvement=required_improvement, improvement_impact=improvement_impact, num_of_failures=asset.get('num_events', 0), down_time=downtime, efficiency=efficiency, birbaum=birbaum)
results.append(contribution)
# Step 4: Sort by Birnbaum importance
results.sort(key=lambda x: x.birbaum, reverse=True)
return results
def project_eaf_improvement(asset: AssetWeight, improvement_factor: float=0.3) -> float:
"""
Project EAF improvement after maintenance
This is a simplified model - you should replace with your actual prediction logic
"""
current_downtime_pct = 100 - asset.eaf
improved_downtime_pct = current_downtime_pct * (1 - improvement_factor)
projected_eaf = 100 - improved_downtime_pct
return min(projected_eaf, 99.9) # Cap at 99.9%
async def identify_worst_eaf_contributors(
*,
simulation_result,
target_eaf: float,
db_session: DbSession,
oh_session_id: str,
collector_db: CollectorDbSession,
simulation_id: str,
duration: int,
po_duration: int,
cut_hours: float = 0, # new optional parameter: how many hours of planned outage user wants to cut
):
"""
Identify equipment that contributes most to plant EAF reduction,
evaluate if target EAF is physically achievable, and optionally
calculate the additional improvement if user cuts scheduled outage.
"""
calc_result = simulation_result["calc_result"]
plant_result = simulation_result["plant_result"]
return min(projected_eaf, 99.9)
async def identify_worst_eaf_contributors(*, simulation_result, target_eaf: float, db_session: DbSession, oh_session_id: str, collector_db: CollectorDbSession, simulation_id: str, duration: int, po_duration: int, cut_hours: float=0):
calc_result = simulation_result['calc_result']
plant_result = simulation_result['plant_result']
eq_results = calc_result if isinstance(calc_result, list) else [calc_result]
# Base parameters
current_plant_eaf = plant_result.get("eaf", 0)
current_plant_eaf = plant_result.get('eaf', 0)
total_hours = duration
scheduled_outage = int(po_duration)
reduced_outage = max(scheduled_outage - cut_hours, 0)
max_eaf_possible = (total_hours - reduced_outage) / total_hours * 100
# Improvement purely from outage reduction (global)
scheduled_eaf_gain = (cut_hours / total_hours) * 100 if cut_hours > 0 else 0.0
# Target feasibility check
scheduled_eaf_gain = cut_hours / total_hours * 100 if cut_hours > 0 else 0.0
warning_message = None
if target_eaf > max_eaf_possible:
impossible_gap = target_eaf - max_eaf_possible
target_eaf - max_eaf_possible
required_scheduled_hours = total_hours * (1 - target_eaf / 100)
required_reduction = reduced_outage - required_scheduled_hours
# Build dynamic phrase for clarity
if cut_hours > 0:
reduction_phrase = f" even after reducing planned outage by {cut_hours}h"
reduction_phrase = f' even after reducing planned outage by {cut_hours}h'
else:
reduction_phrase = ""
warning_message = (
f"⚠️ Target EAF {target_eaf:.2f}% exceeds theoretical maximum {max_eaf_possible:.2f}%"
f"{reduction_phrase}.\n"
f"To achieve it, planned outage must be further reduced by approximately "
f"{required_reduction:.1f} hours (from {reduced_outage:.0f}h → {required_scheduled_hours:.0f}h)."
)
# Cap target EAF to max achievable for calculation
reduction_phrase = ''
warning_message = f'⚠️ Target EAF {target_eaf:.2f}% exceeds theoretical maximum {max_eaf_possible:.2f}%{reduction_phrase}.\nTo achieve it, planned outage must be further reduced by approximately {required_reduction:.1f} hours (from {reduced_outage:.0f}h → {required_scheduled_hours:.0f}h).'
target_eaf = max_eaf_possible
eaf_gap = (target_eaf - current_plant_eaf) / 100.0
if eaf_gap <= 0:
return OptimizationResult(
current_plant_eaf=current_plant_eaf,
target_plant_eaf=target_eaf,
possible_plant_eaf=current_plant_eaf,
eaf_gap=0,
warning_message=warning_message or "Target already achieved or exceeded.",
asset_contributions=[],
optimization_success=True,
simulation_id=simulation_id,
eaf_improvement_text=""
)
# Get standard scope (equipment in OH)
standard_scope = await get_standard_scope_by_session_id(
db_session=db_session,
overhaul_session_id=oh_session_id,
collector_db=collector_db,
)
return OptimizationResult(current_plant_eaf=current_plant_eaf, target_plant_eaf=target_eaf, possible_plant_eaf=current_plant_eaf, eaf_gap=0, warning_message=warning_message or 'Target already achieved or exceeded.', asset_contributions=[], optimization_success=True, simulation_id=simulation_id, eaf_improvement_text='')
standard_scope = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=oh_session_id, collector_db=collector_db)
standard_scope_location_tags = [tag.location_tag for tag in standard_scope]
# Compute contributions for reliability improvements
asset_contributions = calculate_asset_eaf_contributions(
plant_result, eq_results, standard_scope_location_tags, eaf_gap, reduced_outage
)
# Greedy improvement allocation
asset_contributions = calculate_asset_eaf_contributions(plant_result, eq_results, standard_scope_location_tags, eaf_gap, reduced_outage)
project_eaf_improvement_total = 0.0
selected_eq = []
for asset in asset_contributions:
if project_eaf_improvement_total >= eaf_gap:
break
if (project_eaf_improvement_total + asset.improvement_impact) <= eaf_gap:
if project_eaf_improvement_total + asset.improvement_impact <= eaf_gap:
selected_eq.append(asset)
project_eaf_improvement_total += asset.improvement_impact
else:
continue
# Total EAF after improvements + optional outage cut
possible_eaf_plant = current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain
possible_eaf_plant = min(possible_eaf_plant, max_eaf_possible)
selected_eq.sort(key=lambda x: x.birbaum, reverse=True)
required_cut_hours = 0
# --- 2. Optimization feasible but cannot reach target (underperformance case) ---
if possible_eaf_plant < target_eaf:
# Calculate shortfall
performance_gap = target_eaf - possible_eaf_plant
# Estimate how many scheduled outage hours must be reduced to close the remaining gap
# Each hour reduced adds (1 / total_hours) * 100 % to plant EAF
required_cut_hours = (performance_gap / 100) * total_hours
reliability_limit_msg = (
f"⚠️ Optimization was unable to reach target EAF {target_eaf:.2f}%.\n"
f"The best achievable EAF based on current reliability is "
f"{possible_eaf_plant:.2f}% (short by {performance_gap:.2f}%)."
)
# Add actionable recommendation
recommendation_msg = (
f"To achieve the target EAF, consider reducing planned outage by approximately "
f"{required_cut_hours:.1f} hours or {int(required_cut_hours/24)} days (from {reduced_outage:.0f}h → {reduced_outage - required_cut_hours:.0f}h)."
)
required_cut_hours = performance_gap / 100 * total_hours
reliability_limit_msg = f'⚠️ Optimization was unable to reach target EAF {target_eaf:.2f}%.\nThe best achievable EAF based on current reliability is {possible_eaf_plant:.2f}% (short by {performance_gap:.2f}%).'
recommendation_msg = f'To achieve the target EAF, consider reducing planned outage by approximately {required_cut_hours:.1f} hours or {int(required_cut_hours / 24)} days (from {reduced_outage:.0f}h → {reduced_outage - required_cut_hours:.0f}h).'
if warning_message:
warning_message = warning_message + "\n\n" + reliability_limit_msg + "\n" + recommendation_msg
warning_message = warning_message + '\n\n' + reliability_limit_msg + '\n' + recommendation_msg
else:
warning_message = reliability_limit_msg + "\n" + recommendation_msg
# --- EAF improvement reporting ---
eaf_improvement_points = (possible_eaf_plant - current_plant_eaf)
# Express as text for user readability
warning_message = reliability_limit_msg + '\n' + recommendation_msg
eaf_improvement_points = possible_eaf_plant - current_plant_eaf
if eaf_improvement_points > 0:
improvement_text = f"{eaf_improvement_points:.6f} percentage points increase"
improvement_text = f'{eaf_improvement_points:.6f} percentage points increase'
else:
improvement_text = "No measurable improvement achieved"
# Build result
return OptimizationResult(
current_plant_eaf=current_plant_eaf,
target_plant_eaf=target_eaf,
possible_plant_eaf=possible_eaf_plant,
eaf_gap=eaf_gap,
warning_message=warning_message, # numeric
eaf_improvement_text=improvement_text,
recommended_reduced_outage=required_cut_hours,
asset_contributions=[
{
"node": asset.node,
"availability": asset.availability,
"contribution": asset.contribution,
"sensitivy": asset.birbaum,
"required_improvement": asset.required_improvement,
"system_impact": asset.improvement_impact,
"num_of_failures": asset.num_of_failures,
"down_time": asset.down_time,
"efficiency": asset.efficiency,
}
for asset in selected_eq
],
outage_reduction_hours=cut_hours,
optimization_success=(current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain)
>= target_eaf,
simulation_id=simulation_id,
)
improvement_text = 'No measurable improvement achieved'
return OptimizationResult(current_plant_eaf=current_plant_eaf, target_plant_eaf=target_eaf, possible_plant_eaf=possible_eaf_plant, eaf_gap=eaf_gap, warning_message=warning_message, eaf_improvement_text=improvement_text, recommended_reduced_outage=required_cut_hours, asset_contributions=[{'node': asset.node, 'availability': asset.availability, 'contribution': asset.contribution, 'sensitivy': asset.birbaum, 'required_improvement': asset.required_improvement, 'system_impact': asset.improvement_impact, 'num_of_failures': asset.num_of_failures, 'down_time': asset.down_time, 'efficiency': asset.efficiency} for asset in selected_eq], outage_reduction_hours=cut_hours, optimization_success=current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain >= target_eaf, simulation_id=simulation_id)

@ -1,91 +1,49 @@
import asyncio
from datetime import datetime, timedelta
import random
from typing import List, Optional
from typing import Optional
from temporalio.client import Client
from src.config import TEMPORAL_URL
from src.config import TEMPORAL_URL, TR_RBD_ID
def generate_down_periods(start_date: datetime, end_date: datetime,
num_periods: Optional[int] = None, min_duration: int = 3,
max_duration: int = 7) -> list[tuple[datetime, datetime]]:
"""
Generate random system down periods within a date range.
Args:
start_date (datetime): Start date of the overall period
end_date (datetime): End date of the overall period
num_periods (int, optional): Number of down periods to generate.
If None, generates 1-3 periods randomly
min_duration (int): Minimum duration of each down period in days
max_duration (int): Maximum duration of each down period in days
Returns:
list[tuple[datetime, datetime]]: List of (start_date, end_date) tuples
for each down period
"""
def generate_down_periods(start_date: datetime, end_date: datetime, num_periods: Optional[int]=None, min_duration: int=3, max_duration: int=7) -> list[tuple[datetime, datetime]]:
if num_periods is None:
num_periods = random.randint(1, 3)
total_days = (end_date - start_date).days
down_periods = []
# Generate random down periods
for _ in range(num_periods):
# Random duration for this period
duration = random.randint(min_duration, max_duration)
# Ensure we don't exceed the total date range
latest_possible_start = total_days - duration
if latest_possible_start < 0:
continue
# Random start day within available range
start_day = random.randint(0, latest_possible_start)
period_start = start_date + timedelta(days=start_day)
period_end = period_start + timedelta(days=duration)
# Check for overlaps with existing periods
overlaps = any(
(p_start <= period_end and period_start <= p_end)
for p_start, p_end in down_periods
)
overlaps = any((p_start <= period_end and period_start <= p_end for p_start, p_end in down_periods))
if not overlaps:
down_periods.append((period_start, period_end))
return sorted(down_periods)
async def wait_for_workflow(simulation_id, max_retries=3):
workflow_id = f"simulation-{simulation_id}" # use returned ID
workflow_id = f'simulation-{simulation_id}'
retries = 0
temporal_client = await Client.connect(TEMPORAL_URL)
while True:
try:
handle = temporal_client.get_workflow_handle(workflow_id=workflow_id)
desc = await handle.describe()
status = desc.status.name
if status not in ["RUNNING", "CONTINUED_AS_NEW"]:
print(f"✅ Workflow {workflow_id} finished with status: {status}")
if status not in ['RUNNING', 'CONTINUED_AS_NEW']:
print(f'✅ Workflow {workflow_id} finished with status: {status}')
break
print(f"⏳ Workflow {workflow_id} still {status}, checking again in 10s...")
print(f'⏳ Workflow {workflow_id} still {status}, checking again in 10s...')
except Exception as e:
retries += 1
if retries > max_retries:
print(f"⚠️ Workflow {workflow_id} not found after {max_retries} retries, treating as done. Error: {e}")
print(f'⚠️ Workflow {workflow_id} not found after {max_retries} retries, treating as done. Error: {e}')
break
else:
print(f"⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...")
print(f'⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...')
await asyncio.sleep(10)
continue
retries = 0 # reset retries if describe() worked
retries = 0
await asyncio.sleep(30)
return simulation_id

@ -6,228 +6,102 @@ from collections import defaultdict
from src.config import BASE_URL
class OptimumCostModel:
def __init__(self, token, last_oh_date, next_oh_date,interval =30, base_url: str = f"{BASE_URL}"):
def __init__(self, token, last_oh_date, next_oh_date, interval=30, base_url: str=f'{BASE_URL}'):
self.api_base_url = base_url
self.token = token
self.last_oh_date = last_oh_date
self.next_oh_date = next_oh_date
self.interval = 30
def get_reliability_from_api(self, target_date, location_tag):
"""
Get reliability value from API for a specific date.
Parameters:
target_date: datetime object for the target date
Returns:
reliability value (float)
"""
# Format date for API call
date_str = target_date.strftime('%Y-%m-%d %H:%M:%S.%f')
# Construct API URL
url = f"{self.api_base_url}/reliability/calculate/reliability/{location_tag}/{date_str}"
header = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + self.token
}
url = f'{self.api_base_url}/reliability/calculate/reliability/{location_tag}/{date_str}'
header = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + self.token}
try:
response = requests.get(url, headers=header)
response.raise_for_status()
data = response.json()
reliability = data['data']['value']
return reliability
return data['data']['value']
except requests.RequestException as e:
print(f"API Error: {e}")
print(f'API Error: {e}')
return None
except KeyError as e:
print(f"Data parsing error: {e}")
print(f'Data parsing error: {e}')
return None
def get_reliability_equipment(self, location_tag):
current_date = self.last_oh_date
results = defaultdict()
print("Fetching reliability data from API...")
print('Fetching reliability data from API...')
while current_date <= self.next_oh_date:
reliability = self.get_reliability_from_api(current_date, location_tag)
results[current_date] = reliability
if reliability is not None:
print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: {reliability:.6f}")
else:
print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: Failed to fetch")
current_date += timedelta(days=self.interval)
return results
def calculate_costs_each_point(self, reliabilities, preventive_cost: float, failure_replacement_cost: float):
# Calculate costs for each time point
results = []
dates = list(reliabilities.keys())
for i, (date, reliability) in enumerate(reliabilities.items()):
if reliability is None:
continue
# Calculate time from last OH in days
time_from_last_oh = (date - self.last_oh_date).days
# Calculate failure replacement cost
failure_prob = 1 - reliability
# For expected operating time, we need to integrate R(t) from 0 to T
# Since we have discrete points, we'll approximate using trapezoidal rule
if i == 0:
expected_operating_time = time_from_last_oh # First point
expected_operating_time = time_from_last_oh
else:
# Approximate integral using available reliability values
time_points = [(dates[j] - self.last_oh_date).days for j in range(i + 1)]
rel_values = [rel for rel in reliabilities[:i + 1] if rel is not None]
if len(rel_values) > 1:
expected_operating_time = np.trapezoid(rel_values, time_points)
else:
expected_operating_time = time_from_last_oh * reliability
# Calculate costs
if expected_operating_time > 0:
failure_cost = (failure_prob * failure_replacement_cost) / expected_operating_time
preventive_cost = (reliability * preventive_cost) / expected_operating_time
failure_cost = failure_prob * failure_replacement_cost / expected_operating_time
preventive_cost = reliability * preventive_cost / expected_operating_time
else:
# failure_cost = failure_prob * self.IDRu
# preventive_cost = reliability * self.IDRp
continue
total_cost = failure_cost + preventive_cost
results.append({
'date': date,
'days_from_last_oh': time_from_last_oh,
'reliability': reliability,
'failure_probability': failure_prob,
'expected_operating_time': expected_operating_time,
'failure_replacement_cost': failure_cost,
'preventive_replacement_cost': preventive_cost,
'total_cost': total_cost,
'procurement_cost': 0
})
results.append({'date': date, 'days_from_last_oh': time_from_last_oh, 'reliability': reliability, 'failure_probability': failure_prob, 'expected_operating_time': expected_operating_time, 'failure_replacement_cost': failure_cost, 'preventive_replacement_cost': preventive_cost, 'total_cost': total_cost, 'procurement_cost': 0})
return results
def find_optimal_timing(self, results_df):
"""
Find the timing that gives the lowest total cost.
Parameters:
results_df: DataFrame from calculate_costs_over_period
Returns:
Dictionary with optimal timing information
"""
if results_df.empty:
return None
# Find minimum total cost
min_cost_idx = results_df['total_cost'].idxmin()
optimal_row = results_df.loc[min_cost_idx]
return {'optimal_index': min_cost_idx, 'optimal_date': optimal_row['date'], 'days_from_last_oh': optimal_row['days_from_last_oh'], 'reliability': optimal_row['reliability'], 'failure_cost': optimal_row['failure_replacement_cost'], 'preventive_cost': optimal_row['preventive_replacement_cost'], 'total_cost': optimal_row['total_cost'], 'procurement_cost': 0}
return {
'optimal_index': min_cost_idx,
'optimal_date': optimal_row['date'],
'days_from_last_oh': optimal_row['days_from_last_oh'],
'reliability': optimal_row['reliability'],
'failure_cost': optimal_row['failure_replacement_cost'],
'preventive_cost': optimal_row['preventive_replacement_cost'],
'total_cost': optimal_row['total_cost'],
'procurement_cost': 0
}
async def calculate_cost_all_equipment(
self,
db_session: DbSession,
equipments: list,
preventive_cost: float,
calculation,
) :
"""
Calculate optimization for entire fleet of equipment
"""
async def calculate_cost_all_equipment(self, db_session: DbSession, equipments: list, preventive_cost: float, calculation):
max_interval = self._get_months_between(start_date, end_date)
preventive_cost_per_equipment = preventive_cost / len(equipments)
preventive_cost / len(equipments)
fleet_results = []
total_corrective_costs = np.zeros(max_interval)
total_preventive_costs = np.zeros(max_interval)
total_procurement_costs = np.zeros(max_interval)
total_failures = np.zeros(max_interval)
np.zeros(max_interval)
for equipment in equipments:
# Get reliability data
cost_per_failure = equipment.material_cost
reliabilities = await self.get_reliability_equipment(
location_tag=equipment.equipment.location_tag,
)
reliabilities = await self.get_reliability_equipment(location_tag=equipment.equipment.location_tag)
predicted_cost = self.calculate_costs_each_point(reliabilities=reliabilities, preventive_cost=preventive_cost, failure_replacement_cost=cost_per_failure)
optimum_cost = self.find_optimal_timing(pd.DataFrame(predicted_cost))
# Aggregate costs
corrective_costs = [r["failure_cost"] for r in predicted_cost]
preventive_costs = [r["preventive_cost"] for r in predicted_cost]
procurement_costs = [r["procurement_cost"] for r in predicted_cost]
procurement_details = [r["procurement_details"] for r in predicted_cost]
failures = [(1-r["reliability"]) for r in predicted_cost]
fleet_results.append(
CalculationEquipmentResult(
corrective_costs=corrective_costs,
overhaul_costs=preventive_costs,
procurement_costs=procurement_costs,
daily_failures=failures,
assetnum=equipment.assetnum,
material_cost=equipment.material_cost,
service_cost=equipment.service_cost,
optimum_day=optimum_cost['optimal_index'],
calculation_data_id=calculation.id,
master_equipment=equipment.equipment,
procurement_details=procurement_details
)
)
corrective_costs = [r['failure_cost'] for r in predicted_cost]
preventive_costs = [r['preventive_cost'] for r in predicted_cost]
procurement_costs = [r['procurement_cost'] for r in predicted_cost]
procurement_details = [r['procurement_details'] for r in predicted_cost]
failures = [1 - r['reliability'] for r in predicted_cost]
fleet_results.append(CalculationEquipmentResult(corrective_costs=corrective_costs, overhaul_costs=preventive_costs, procurement_costs=procurement_costs, daily_failures=failures, assetnum=equipment.assetnum, material_cost=equipment.material_cost, service_cost=equipment.service_cost, optimum_day=optimum_cost['optimal_index'], calculation_data_id=calculation.id, master_equipment=equipment.equipment, procurement_details=procurement_details))
total_corrective_costs += np.array(corrective_costs)
total_preventive_costs += np.array(preventive_costs)
total_procurement_costs += np.array(procurement_costs)
# Calculate fleet optimal interval
total_costs = total_corrective_costs + total_preventive_costs + total_procurement_costs
fleet_optimal_index = np.argmin(total_costs)
calculation.optimum_oh_day = fleet_optimal_index + 1
db_session.add_all(fleet_results)
await db_session.commit()
return {
'id': calculation.id,
'fleet_results': fleet_results,
'fleet_optimal_interval': fleet_optimal_index + 1,
'fleet_optimal_cost': total_costs[fleet_optimal_index],
'total_corrective_costs': total_corrective_costs.tolist(),
'total_preventive_costs': total_preventive_costs.tolist(),
'total_procurement_costs': total_procurement_costs.tolist(),
}
return {'id': calculation.id, 'fleet_results': fleet_results, 'fleet_optimal_interval': fleet_optimal_index + 1, 'fleet_optimal_cost': total_costs[fleet_optimal_index], 'total_corrective_costs': total_corrective_costs.tolist(), 'total_preventive_costs': total_preventive_costs.tolist(), 'total_procurement_costs': total_procurement_costs.tolist()}

@ -1,135 +1,36 @@
from typing import Optional
from uuid import UUID
import numpy as np
from fastapi import HTTPException, status
from sqlalchemy import Select, func, select
from sqlalchemy.orm import joinedload
from src.auth.service import Token
from sqlalchemy import func, select
from src.config import TC_RBD_ID
from src.database.core import DbSession
from src.overhaul_scope.service import get_all
from src.standard_scope.model import StandardScope
from src.workorder.model import MasterWorkOrder
from .schema import (CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsParametersRead,
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsRead)
from .service import (create_calculation_result_service, create_param_and_data,
get_avg_cost_by_asset,
get_calculation_by_reference_and_parameter,
get_calculation_data_by_id, get_calculation_result,
run_simulation_with_spareparts)
from .schema import CalculationTimeConstrainsParametersCreate, CalculationTimeConstrainsParametersRead, CalculationTimeConstrainsParametersRetrive
from .service import create_param_and_data, get_calculation_data_by_id, run_simulation_with_spareparts
from src.database.core import CollectorDbSession
async def get_create_calculation_parameters(
*, db_session: DbSession, calculation_id: Optional[str] = None
):
async def get_create_calculation_parameters(*, db_session: DbSession, calculation_id: Optional[str]=None):
if calculation_id is not None:
calculation = await get_calculation_data_by_id(
calculation_id=calculation_id, db_session=db_session
)
calculation = await get_calculation_data_by_id(calculation_id=calculation_id, db_session=db_session)
if not calculation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return CalculationTimeConstrainsParametersRead(
costPerFailure=calculation.parameter.avg_failure_cost,
overhaulCost=calculation.parameter.overhaul_cost,
reference=calculation,
)
stmt = (
select(
StandardScope,
func.avg(MasterWorkOrder.total_cost_max).label("average_cost"),
)
.outerjoin(MasterWorkOrder, StandardScope.location_tag == MasterWorkOrder.location_tag)
.group_by(StandardScope.id)
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
return CalculationTimeConstrainsParametersRead(costPerFailure=calculation.parameter.avg_failure_cost, overhaulCost=calculation.parameter.overhaul_cost, reference=calculation)
stmt = select(StandardScope, func.avg(MasterWorkOrder.total_cost_max).label('average_cost')).outerjoin(MasterWorkOrder, StandardScope.location_tag == MasterWorkOrder.location_tag).group_by(StandardScope.id)
results = await db_session.execute(stmt)
costFailure = results.all()
scopes = await get_all(db_session=db_session)
avaiableScopes = {scope.id: scope.scope_name for scope in scopes}
costFailurePerScope = {
avaiableScopes.get(costPerFailure[0]): costPerFailure[1]
for costPerFailure in costFailure
}
return CalculationTimeConstrainsParametersRetrive(
costPerFailure=costFailurePerScope,
availableScopes=avaiableScopes.values(),
recommendedScope="A",
# historicalData={
# "averageOverhaulCost": 10000000,
# "lastCalculation": {
# "id": "calc_122",
# "date": "2024-10-15",
# "scope": "B",
# },
# },
)
async def create_calculation(
*,
token: str,
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate,
created_by: str,
simulation_id
):
calculation_data = await create_param_and_data(
db_session=db_session,
calculation_param_in=calculation_time_constrains_in,
created_by=created_by,
)
costFailurePerScope = {avaiableScopes.get(costPerFailure[0]): costPerFailure[1] for costPerFailure in costFailure}
return CalculationTimeConstrainsParametersRetrive(costPerFailure=costFailurePerScope, availableScopes=avaiableScopes.values(), recommendedScope='A')
async def create_calculation(*, token: str, db_session: DbSession, collector_db_session: CollectorDbSession, calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate, created_by: str, simulation_id):
calculation_data = await create_param_and_data(db_session=db_session, calculation_param_in=calculation_time_constrains_in, created_by=created_by)
rbd_simulation_id = simulation_id or TC_RBD_ID
return await run_simulation_with_spareparts(db_session=db_session, calculation=calculation_data, token=token, collector_db_session=collector_db_session, simulation_id=rbd_simulation_id)
# results = await create_calculation_result_service(
# db_session=db_session, calculation=calculation_data, token=token
# )
results = await run_simulation_with_spareparts(
db_session=db_session, calculation=calculation_data, token=token, collector_db_session=collector_db_session, simulation_id=rbd_simulation_id
)
return results
async def get_or_create_scope_equipment_calculation(
*,
db_session: DbSession,
scope_calculation_id,
calculation_time_constrains_in: Optional[CalculationTimeConstrainsParametersCreate]
):
scope_calculation = await get_calculation_data_by_id(
db_session=db_session, calculation_id=scope_calculation_id
)
async def get_or_create_scope_equipment_calculation(*, db_session: DbSession, scope_calculation_id, calculation_time_constrains_in: Optional[CalculationTimeConstrainsParametersCreate]):
scope_calculation = await get_calculation_data_by_id(db_session=db_session, calculation_id=scope_calculation_id)
if not scope_calculation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
return scope_calculation.id
# Check if calculation already exist
# return CalculationTimeConstrainsRead(
# id=scope_calculation.id,
# reference=scope_calculation.overhaul_session_id,
# results=scope_calculation.results,
# optimum_oh=scope_calculation.optimum_oh_day,
# equipment_results=scope_calculation.equipment_results,
# )

@ -1,150 +1,60 @@
from enum import Enum
from typing import List, Optional, Union
from sqlalchemy import (JSON, UUID, Boolean, Column, Float, ForeignKey,
Integer, Numeric, String)
from typing import Optional
from sqlalchemy import JSON, UUID, Boolean, Column, Float, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from src.database.core import Base, DbSession
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin, UUIDMixin
from src.models import DefaultMixin, IdentityMixin
class OverhaulReferenceType(str, Enum):
SCOPE = "SCOPE"
ASSET = "ASSET"
SCOPE = 'SCOPE'
ASSET = 'ASSET'
class CalculationParam(Base, DefaultMixin, IdentityMixin):
__tablename__ = "oh_ms_calculation_param"
__tablename__ = 'oh_ms_calculation_param'
avg_failure_cost = Column(Float, nullable=False)
overhaul_cost = Column(Float, nullable=False)
# Relationships
calculation_data = relationship("CalculationData", back_populates="parameter")
results = relationship("CalculationResult", back_populates="parameter")
# @classmethod
# async def create_with_references(
# cls,
# db: DbSession,
# avg_failure_cost: float,
# overhaul_cost: float,
# created_by: str,
# # list of {"reference_type": OverhaulReferenceType, "reference_id": str}
# references: List[dict]
# ):
# # Create parameter
# param = cls(
# avg_failure_cost=avg_failure_cost,
# overhaul_cost=overhaul_cost,
# created_by=created_by
# )
# db.add(param)
# await db.flush() # Flush to get the param.id
# # Create reference links
# for ref in references:
# reference_link = ReferenceLink(
# parameter_id=param.id,
# overhaul_reference_type=ref["reference_type"],
# reference_id=ref["reference_id"]
# )
# db.add(reference_link)
# await db.commit()
# await db.refresh(param)
# return param
calculation_data = relationship('CalculationData', back_populates='parameter')
results = relationship('CalculationResult', back_populates='parameter')
class CalculationData(Base, DefaultMixin, IdentityMixin):
__tablename__ = "oh_tr_calculation_data"
parameter_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_calculation_param.id"), nullable=True
)
overhaul_session_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_overhaul.id")
)
__tablename__ = 'oh_tr_calculation_data'
parameter_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_calculation_param.id'), nullable=True)
overhaul_session_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_overhaul.id'))
optimum_oh_day = Column(Integer, nullable=True)
max_interval = Column(Integer, nullable=True)
rbd_simulation_id = Column(UUID(as_uuid=True), nullable=True)
optimum_analysis = Column(JSON, nullable=True)
session = relationship("OverhaulScope", lazy="raise")
parameter = relationship("CalculationParam", back_populates="calculation_data")
equipment_results = relationship(
"CalculationEquipmentResult", lazy="raise", viewonly=True
)
results = relationship("CalculationResult", lazy="raise", viewonly=True)
session = relationship('OverhaulScope', lazy='raise')
parameter = relationship('CalculationParam', back_populates='calculation_data')
equipment_results = relationship('CalculationEquipmentResult', lazy='raise', viewonly=True)
results = relationship('CalculationResult', lazy='raise', viewonly=True)
@classmethod
async def create_with_param(
cls,
overhaul_session_id: str,
db: DbSession,
avg_failure_cost: Optional[float],
overhaul_cost: Optional[float],
created_by: str,
params_id: Optional[UUID],
):
async def create_with_param(cls, overhaul_session_id: str, db: DbSession, avg_failure_cost: Optional[float], overhaul_cost: Optional[float], created_by: str, params_id: Optional[UUID]):
if not params_id:
# Create Params
params = CalculationParam(
avg_failure_cost=avg_failure_cost,
overhaul_cost=overhaul_cost,
created_by=created_by,
)
params = CalculationParam(avg_failure_cost=avg_failure_cost, overhaul_cost=overhaul_cost, created_by=created_by)
db.add(params)
await db.flush()
params_id = params.id
calculation_data = cls(
overhaul_session_id=overhaul_session_id,
created_by=created_by,
parameter_id=params_id,
)
calculation_data = cls(overhaul_session_id=overhaul_session_id, created_by=created_by, parameter_id=params_id)
db.add(calculation_data)
await db.commit()
await db.refresh(calculation_data)
return calculation_data
class CalculationResult(Base, DefaultMixin):
__tablename__ = "oh_tr_calculation_result"
parameter_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_calculation_param.id"), nullable=False
)
calculation_data_id = Column(
UUID(as_uuid=True), ForeignKey("oh_tr_calculation_data.id"), nullable=False
)
__tablename__ = 'oh_tr_calculation_result'
parameter_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_calculation_param.id'), nullable=False)
calculation_data_id = Column(UUID(as_uuid=True), ForeignKey('oh_tr_calculation_data.id'), nullable=False)
day = Column(Integer, nullable=False)
corrective_cost = Column(Float, nullable=False)
overhaul_cost = Column(Float, nullable=False)
num_failures = Column(Integer, nullable=False)
parameter = relationship("CalculationParam", back_populates="results")
reference_link = relationship("CalculationData")
parameter = relationship('CalculationParam', back_populates='results')
reference_link = relationship('CalculationData')
class CalculationEquipmentResult(Base, DefaultMixin):
__tablename__ = "oh_tr_calculation_equipment_result"
__tablename__ = 'oh_tr_calculation_equipment_result'
corrective_costs = Column(JSON, nullable=False)
overhaul_costs = Column(JSON, nullable=False)
daily_failures = Column(JSON, nullable=False)
@ -152,17 +62,9 @@ class CalculationEquipmentResult(Base, DefaultMixin):
location_tag = Column(String(255), nullable=False)
material_cost = Column(Float, nullable=False)
service_cost = Column(Float, nullable=False)
calculation_data_id = Column(
UUID(as_uuid=True), ForeignKey("oh_tr_calculation_data.id"), nullable=True
)
calculation_data_id = Column(UUID(as_uuid=True), ForeignKey('oh_tr_calculation_data.id'), nullable=True)
optimum_day = Column(Integer, default=1)
is_included = Column(Boolean, default=True)
procurement_details = Column(JSON, nullable=True)
is_initial = Column(Boolean, default=True)
master_equipment = relationship(
"MasterEquipment",
lazy="joined",
primaryjoin="and_(CalculationEquipmentResult.location_tag == foreign(MasterEquipment.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)
master_equipment = relationship('MasterEquipment', lazy='joined', primaryjoin='and_(CalculationEquipmentResult.location_tag == foreign(MasterEquipment.location_tag))', uselist=False)

@ -1,198 +1,61 @@
from typing import Annotated, List, Optional, Union
from fastapi import APIRouter
from fastapi.params import Query
import requests
from src import config
from src.auth.service import CurrentUser, InternalKey, Token
from src.config import DEFAULT_TC_ID
from src.database.core import DbSession
from src.models import StandardResponse
from .flows import (create_calculation, get_create_calculation_parameters,
get_or_create_scope_equipment_calculation)
from .schema import (CalculationResultsRead,
CalculationSelectedEquipmentUpdate,
CalculationTimeConstrainsCreate,
CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsParametersRead,
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsRead, CreateCalculationQuery, EquipmentResult, CalculationTimeConstrainsReadNoResult)
from .service import (bulk_update_equipment, get_calculation_result,
get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations)
from .flows import create_calculation, get_create_calculation_parameters, get_or_create_scope_equipment_calculation
from .schema import CalculationResultsRead, CalculationSelectedEquipmentUpdate, CalculationTimeConstrainsCreate, CalculationTimeConstrainsParametersCreate, CalculationTimeConstrainsParametersRead, CalculationTimeConstrainsParametersRetrive, CalculationTimeConstrainsRead, CreateCalculationQuery, EquipmentResult, CalculationTimeConstrainsReadNoResult
from .service import bulk_update_equipment, get_calculation_result, get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations
from src.database.core import CollectorDbSession
from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends
from src.csrf_protect import csrf_protect
router = APIRouter()
get_calculation = APIRouter()
@router.post(
"", response_model=StandardResponse[Union[dict, CalculationTimeConstrainsRead]],
dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))]
)
async def create_calculation_time_constrains(
token: Token,
db_session: DbSession,
collector_db_session: CollectorDbSession,
current_user: CurrentUser,
calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate,
params: Annotated[CreateCalculationQuery, Query()],
# scope_calculation_id: Optional[str] = Query(None),
# with_results: Optional[int] = Query(0),
# simulation_id = Query(None)
):
"""Save calculation time constrains Here"""
@router.post('', response_model=StandardResponse[Union[dict, CalculationTimeConstrainsRead]], dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))])
async def create_calculation_time_constrains(token: Token, db_session: DbSession, collector_db_session: CollectorDbSession, current_user: CurrentUser, calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate, params: Annotated[CreateCalculationQuery, Query()]):
scope_calculation_id = params.scope_calculation_id
with_results = params.with_results
simulation_id = params.simulation_id
if scope_calculation_id:
results = await get_or_create_scope_equipment_calculation(
db_session=db_session,
scope_calculation_id=scope_calculation_id,
calculation_time_constrains_in=calculation_time_constrains_in,
)
results = await get_or_create_scope_equipment_calculation(db_session=db_session, scope_calculation_id=scope_calculation_id, calculation_time_constrains_in=calculation_time_constrains_in)
else:
results = await create_calculation(
token=token,
db_session=db_session,
collector_db_session=collector_db_session,
calculation_time_constrains_in=calculation_time_constrains_in,
created_by=current_user.name,
simulation_id=simulation_id
)
return StandardResponse(data=results, message="Data created successfully")
@router.get(
"", response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_all_simulation_calculations(
db_session: DbSession,
token: Token,
current_user: CurrentUser,
):
"""Get all calculation time constrains Here"""
calculations = await get_all_calculations(
db_session=db_session,
)
return StandardResponse(
data=calculations,
message="Data retrieved successfully",
)
results = await create_calculation(token=token, db_session=db_session, collector_db_session=collector_db_session, calculation_time_constrains_in=calculation_time_constrains_in, created_by=current_user.name, simulation_id=simulation_id)
return StandardResponse(data=results, message='Data created successfully')
@router.get(
"/parameters",
response_model=StandardResponse[
Union[
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsParametersRead,
]
],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_calculation_parameters(
db_session: DbSession, calculation_id: Optional[str] = Query(default=None)
):
"""Get all calculation parameter."""
@router.get('', response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_all_simulation_calculations(db_session: DbSession, token: Token, current_user: CurrentUser):
calculations = await get_all_calculations(db_session=db_session)
return StandardResponse(data=calculations, message='Data retrieved successfully')
parameters = await get_create_calculation_parameters(
db_session=db_session, calculation_id=calculation_id
)
@router.get('/parameters', response_model=StandardResponse[Union[CalculationTimeConstrainsParametersRetrive, CalculationTimeConstrainsParametersRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_calculation_parameters(db_session: DbSession, calculation_id: Optional[str]=Query(default=None)):
parameters = await get_create_calculation_parameters(db_session=db_session, calculation_id=calculation_id)
return StandardResponse(data=parameters, message='Data retrieved successfully')
return StandardResponse(
data=parameters,
message="Data retrieved successfully",
)
@get_calculation.get(
"/{calculation_id}", response_model=StandardResponse[CalculationTimeConstrainsRead],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_calculation_results(db_session: DbSession, calculation_id, token:InternalKey, include_risk_cost:int = Query(1, alias="risk_cost")):
@get_calculation.get('/{calculation_id}', response_model=StandardResponse[CalculationTimeConstrainsRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_calculation_results(db_session: DbSession, calculation_id, token: InternalKey, include_risk_cost: int=Query(1, alias='risk_cost')):
if calculation_id == 'default':
calculation_id = DEFAULT_TC_ID
results = await get_calculation_result(db_session=db_session, calculation_id=calculation_id, token=token, include_risk_cost=include_risk_cost)
return StandardResponse(data=results, message='Data retrieved successfully')
results = await get_calculation_result(
db_session=db_session, calculation_id=calculation_id, token=token, include_risk_cost=include_risk_cost
)
# requests.post(f"{config.AUTH_SERVICE_API}/sign-out", headers={
# "Authorization": f"Bearer {token}"
# })
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.get(
"/{calculation_id}/{assetnum}", response_model=StandardResponse[EquipmentResult],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
@router.get('/{calculation_id}/{assetnum}', response_model=StandardResponse[EquipmentResult], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_calculation_per_equipment(db_session: DbSession, calculation_id, assetnum):
results = await get_calculation_by_assetnum(db_session=db_session, assetnum=assetnum, calculation_id=calculation_id)
return StandardResponse(data=results, message='Data retrieved successfully')
results = await get_calculation_by_assetnum(
db_session=db_session, assetnum=assetnum, calculation_id=calculation_id
)
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.post('/{calculation_id}/simulation', response_model=StandardResponse[CalculationResultsRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_simulation_result(db_session: DbSession, calculation_id, calculation_simuation_in: CalculationTimeConstrainsCreate):
simulation_result = await get_calculation_result_by_day(db_session=db_session, calculation_id=calculation_id, simulation_day=calculation_simuation_in.intervalDays)
return StandardResponse(data=simulation_result, message='Data retrieved successfully')
@router.post(
"/{calculation_id}/simulation",
response_model=StandardResponse[CalculationResultsRead],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_simulation_result(
db_session: DbSession,
calculation_id,
calculation_simuation_in: CalculationTimeConstrainsCreate,
):
simulation_result = await get_calculation_result_by_day(
db_session=db_session,
calculation_id=calculation_id,
simulation_day=calculation_simuation_in.intervalDays,
)
return StandardResponse(
data=simulation_result, message="Data retrieved successfully"
)
@router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope)), Depends(csrf_protect)])
async def update_selected_equipment(
db_session: DbSession,
calculation_id,
calculation_time_constrains_in: List[CalculationSelectedEquipmentUpdate],
):
@router.post('/update/{calculation_id}', response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope)), Depends(csrf_protect)])
async def update_selected_equipment(db_session: DbSession, calculation_id, calculation_time_constrains_in: List[CalculationSelectedEquipmentUpdate]):
if calculation_id == 'default':
calculation_id = "3b9a73a2-bde6-418c-9e2f-19046f501a05"
results = await bulk_update_equipment(
db=db_session,
selected_equipments=calculation_time_constrains_in,
calculation_data_id=calculation_id,
)
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
calculation_id = '3b9a73a2-bde6-418c-9e2f-19046f501a05'
results = await bulk_update_equipment(db=db_session, selected_equipments=calculation_time_constrains_in, calculation_data_id=calculation_id)
return StandardResponse(data=results, message='Data retrieved successfully')

@ -1,50 +1,32 @@
from src.overhaul_scope.schema import ScopeRead
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
from typing import Dict, List, Optional, Union
from uuid import UUID
from pydantic import Field
from src.models import DefultBase
from src.standard_scope.schema import MasterEquipmentBase
class CalculationTimeConstrainsBase(DefultBase):
pass
class ReferenceLinkBase(DefultBase):
reference_id: str = Field(..., description="Reference ID")
overhaul_reference_type: str = Field(..., description="Overhaul reference type")
reference_id: str = Field(..., description='Reference ID')
overhaul_reference_type: str = Field(..., description='Overhaul reference type')
class CalculationTimeConstrainsParametersRetrive(CalculationTimeConstrainsBase):
# type: ignore
costPerFailure: Union[dict, float] = Field(..., description="Cost per failure")
availableScopes: List[str] = Field(..., description="Available scopes")
recommendedScope: str = Field(..., description="Recommended scope")
# historicalData: Dict[str, Any] = Field(..., description="Historical data")
costPerFailure: Union[dict, float] = Field(..., description='Cost per failure')
availableScopes: List[str] = Field(..., description='Available scopes')
recommendedScope: str = Field(..., description='Recommended scope')
class CalculationTimeConstrainsParametersRead(CalculationTimeConstrainsBase):
costPerFailure: Union[dict, float] = Field(..., description="Cost per failure")
overhaulCost: Optional[float] = Field(None, description="Overhaul cost")
reference: Optional[List[ReferenceLinkBase]] = Field(None, description="Reference")
costPerFailure: Union[dict, float] = Field(..., description='Cost per failure')
overhaulCost: Optional[float] = Field(None, description='Overhaul cost')
reference: Optional[List[ReferenceLinkBase]] = Field(None, description='Reference')
class CalculationTimeConstrainsParametersCreate(CalculationTimeConstrainsBase):
overhaulCost: Optional[float] = Field(0, description="Overhaul cost")
ohSessionId: Optional[UUID] = Field(None, description="Scope OH")
costPerFailure: Optional[float] = Field(0, description="Cost per failure")
# class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase):
# overhaulCost: float = Field(..., description="Overhaul cost")
# scopeOH: str = Field(..., description="Scope OH")
# costPerFailure: float = Field(..., description="Cost per failure")
# metadata: Dict[str, Any] = Field(..., description="Metadata")
overhaulCost: Optional[float] = Field(0, description='Overhaul cost')
ohSessionId: Optional[UUID] = Field(None, description='Scope OH')
costPerFailure: Optional[float] = Field(0, description='Cost per failure')
class CalculationResultsRead(CalculationTimeConstrainsBase):
overhaul_cost: float
@ -57,14 +39,12 @@ class CalculationResultsRead(CalculationTimeConstrainsBase):
procurement_details: Dict
sparepart_summary: dict
class OptimumResult(CalculationTimeConstrainsBase):
overhaul_cost: float
corrective_cost: float
num_failures: int
days: int
class EquipmentResult(CalculationTimeConstrainsBase):
id: UUID
corrective_costs: List[float]
@ -74,7 +54,7 @@ class EquipmentResult(CalculationTimeConstrainsBase):
location_tag: str
material_cost: float
service_cost: float
optimum_day: int # Added optimum result for each equipment
optimum_day: int
is_included: bool
master_equipment: Optional[MasterEquipmentBase] = Field(None)
@ -112,10 +92,6 @@ class CalculationTimeConstrainsReadNoResult(CalculationTimeConstrainsBase):
max_interval: Optional[int]
optimum_analysis: Optional[dict]
session: ScopeRead
# optimum_oh_day: int
# max_interval: int
# optimal_analysis: dict
# analysis_metadata: dict
class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase):
id: UUID
@ -129,15 +105,12 @@ class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase):
optimal_analysis: dict
analysis_metadata: dict
class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase):
intervalDays: int
class CalculationTimeConstrainsSimulationRead(CalculationTimeConstrainsBase):
simulation: CalculationResultsRead
class CalculationSelectedEquipmentUpdate(CalculationTimeConstrainsBase):
is_included: bool
location_tag: str

File diff suppressed because it is too large Load Diff

@ -1,302 +1,134 @@
import datetime
import json
import pandas as pd
import requests
from src.config import RBD_SERVICE_API
def get_months_between(start_date: datetime.datetime, end_date: datetime.datetime) -> int:
"""
Calculate number of months between two dates.
"""
months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month)
# Add 1 to include both start and end months
return months
return (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month)
def create_time_series_data(chart_data, max_hours=None):
# Filter out ON_OH
filtered_data = [d for d in chart_data if d["currentEvent"] != "ON_OH"]
sorted_data = sorted(filtered_data, key=lambda x: x["cumulativeTime"])
filtered_data = [d for d in chart_data if d['currentEvent'] != 'ON_OH']
sorted_data = sorted(filtered_data, key=lambda x: x['cumulativeTime'])
if not sorted_data:
return []
hourly_data = []
current_state_index = 0
current_flow_rate = sorted_data[0]["flowRate"]
current_eq_status = sorted_data[0]["currentEQStatus"]
# Determine maximum bound (either given or from data)
last_time = int(sorted_data[-1]["cumulativeTime"])
current_flow_rate = sorted_data[0]['flowRate']
current_eq_status = sorted_data[0]['currentEQStatus']
last_time = int(sorted_data[-1]['cumulativeTime'])
if max_hours is None:
max_hours = last_time
for hour in range(0, max_hours + 1): # start from 0
# Advance state if needed
while (current_state_index < len(sorted_data) - 1 and
hour >= sorted_data[current_state_index + 1]["cumulativeTime"]):
for hour in range(0, max_hours + 1):
while current_state_index < len(sorted_data) - 1 and hour >= sorted_data[current_state_index + 1]['cumulativeTime']:
current_state_index += 1
current_flow_rate = sorted_data[current_state_index]["flowRate"]
current_eq_status = sorted_data[current_state_index]["currentEQStatus"]
hourly_data.append({
"cumulativeTime": hour,
"flowRate": current_flow_rate,
"currentEQStatus": current_eq_status
})
current_flow_rate = sorted_data[current_state_index]['flowRate']
current_eq_status = sorted_data[current_state_index]['currentEQStatus']
hourly_data.append({'cumulativeTime': hour, 'flowRate': current_flow_rate, 'currentEQStatus': current_eq_status})
return hourly_data
def calculate_failures_per_month(hourly_data):
"""
Calculate the cumulative number of failures up to each month from hourly data.
A failure is defined as when currentEQStatus = "OoS".
Only counts the start of each failure period (transition from "Svc" to "OoS").
Args:
hourly_data: List of dicts with 'hour', 'flowrate', and 'currentEQStatus' keys
Returns:
List of dicts with 'month' and 'failures' keys (cumulative count)
"""
total_failures = 0
previous_eq_status = None
monthly_data = {}
for data_point in hourly_data:
hour = data_point['hour']
current_eq_status = data_point['currentEQStatus']
# Calculate which month this hour belongs to (1-based)
# Assuming 30 days per month = 720 hours per month
month = ((hour - 1) // 720) + 1
# Check if this is the start of a failure (transition to "OoS")
if current_eq_status == "OoS" and previous_eq_status is not None and previous_eq_status != "OoS":
month = (hour - 1) // 720 + 1
if current_eq_status == 'OoS' and previous_eq_status is not None and (previous_eq_status != 'OoS'):
total_failures += 1
# Special case: if the very first data point is a failure
elif current_eq_status == "OoS" and previous_eq_status is None:
elif current_eq_status == 'OoS' and previous_eq_status is None:
total_failures += 1
# Store the current cumulative count for this month
monthly_data[month] = total_failures
previous_eq_status = current_eq_status
# Convert to list format
result = []
if monthly_data:
max_month = max(monthly_data.keys())
for month in range(1, max_month + 1):
result.append({
'month': month,
'failures': monthly_data.get(month, monthly_data.get(month-1, 0))
})
result.append({'month': month, 'failures': monthly_data.get(month, monthly_data.get(month - 1, 0))})
return result
import pandas as pd
import datetime
import datetime
import pandas as pd
async def plant_simulation_metrics(simulation_id: str, location_tag: str, max_interval, token, last_oh_date, use_location_tag: int=1):
"""Get failure predictions for equipment from simulation service"""
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}"
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}'
try:
response = requests.get(
calc_result_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
timeout=30
)
response = requests.get(calc_result_url, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}, timeout=30)
response.raise_for_status()
prediction_data = response.json()['data']
except (requests.RequestException, ValueError) as e:
raise Exception(str(e))
return prediction_data
def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float=550):
if not timestamp_outs:
return {}
df = pd.DataFrame(timestamp_outs)
required_columns = ['cumulativeTime', 'currentEQStatus', 'flowRate']
if not all(col in df.columns for col in required_columns):
if not all((col in df.columns for col in required_columns)):
return {}
start_oh = datetime.datetime(start_date.year, start_date.month, start_date.day)
# Actual datetime from cumulative hours
df['datetime'] = df['cumulativeTime'].apply(lambda x: start_oh + datetime.timedelta(hours=x))
df['month_year'] = df['datetime'].dt.to_period('M')
# Duration until next timestamp
df['duration_hours'] = df['cumulativeTime'].shift(-1) - df['cumulativeTime']
df['duration_hours'] = df['duration_hours'].fillna(0)
# Failure detection
df['status_change'] = df['currentEQStatus'].shift() != df['currentEQStatus']
df['failure'] = (df['currentEQStatus'] == 'OoS') & df['status_change']
# Cumulative tracking
df['cumulative_failures'] = df['failure'].cumsum()
df['cumulative_oos'] = (df['duration_hours'] * (df['currentEQStatus'] == 'OoS')).cumsum()
# Derating calculation
# Derating = capacity reduction below max but not outage
df['derating'] = (max_flow_rate - df['flowRate']).clip(lower=0)
df['is_derated'] = (df['currentEQStatus'] == 'Svc') & (df['derating'] > 0)
# Equivalent Derated Hours (EFDH) → sum of derating * hours, then normalized by max capacity
df['derated_mwh'] = df['derating'] * df['duration_hours']
df['derated_hours_equivalent'] = df['derated_mwh'] / max_flow_rate
monthly_results = {}
for month_period, group in df.groupby('month_year', sort=True):
month_str = str(month_period)
monthly_results[month_str] = {}
# Failures
monthly_results[month_str]['failures_count'] = int(group['failure'].sum())
monthly_results[month_str]['cumulative_failures'] = int(group['cumulative_failures'].max())
# OOS hours
oos_time = group.loc[group['currentEQStatus'] == 'OoS', 'duration_hours'].sum()
monthly_results[month_str]['total_oos_hours'] = float(oos_time)
monthly_results[month_str]['cummulative_oos'] = float(group['cumulative_oos'].max())
# Flow rate (weighted average)
total_flow_time = (group['flowRate'] * group['duration_hours']).sum()
total_time = group['duration_hours'].sum()
avg_flow_rate = total_flow_time / total_time if total_time > 0 else 0
monthly_results[month_str]['avg_flow_rate'] = float(avg_flow_rate)
# Extra metrics
monthly_results[month_str]['total_hours'] = float(total_time)
service_hours = group.loc[group['currentEQStatus'] == 'Svc', 'duration_hours'].sum()
monthly_results[month_str]['service_hours'] = float(service_hours)
monthly_results[month_str]['availability_percentage'] = float(
(service_hours / total_time * 100) if total_time > 0 else 0
)
# Derating metrics
monthly_results[month_str]['availability_percentage'] = float(service_hours / total_time * 100 if total_time > 0 else 0)
derating_hours = group.loc[group['is_derated'], 'duration_hours'].sum()
derated_mwh = group['derated_mwh'].sum()
equivalent_derated_hours = group['derated_hours_equivalent'].sum()
monthly_results[month_str]['derating_hours'] = float(derating_hours)
monthly_results[month_str]['derated_mwh'] = float(derated_mwh)
monthly_results[month_str]['equivalent_derated_hours'] = float(equivalent_derated_hours)
return monthly_results
def calculate_risk_cost_per_failure(monthly_results, birnbaum_importance, energy_price):
"""
Calculate risk cost per failure for each month based on:
1. Equipment capacity contribution to system (flowrate * birnbaum_importance * availability)
2. Capacity lost to downtime per month
3. Energy price
Parameters:
- monthly_results: Output from analyze_monthly_metrics()
- birnbaum_importance: Birnbaum importance factor for this equipment
- energy_price: Price per unit of energy/flow
Returns:
- Dictionary with monthly risk costs and array of risk costs per failure
"""
risk_costs = {}
risk_cost_array = []
for month, data in monthly_results.items():
# Extract monthly data
avg_flow_rate = data['avg_flow_rate']
availability = data['availability_percentage'] / 100 # Convert to decimal
availability = data['availability_percentage'] / 100
total_oos_hours = data['total_oos_hours']
failures_count = data['failures_count']
# 1. Calculate equipment capacity contribution to system
# Capacity = avg_flowrate * birnbaum_importance * availability
equipment_capacity = avg_flow_rate * birnbaum_importance * availability
# 2. Calculate capacity lost to downtime per month
# Lost capacity = avg_flowrate * birnbaum_importance * downtime_hours
capacity_lost_to_downtime = avg_flow_rate * birnbaum_importance * total_oos_hours
# 3. Calculate total risk cost for the month
# Risk cost = capacity_lost * energy_price
monthly_risk_cost = capacity_lost_to_downtime * energy_price
# 4. Calculate risk cost per failure for this month
if failures_count > 0:
risk_cost_per_failure = monthly_risk_cost / failures_count
else:
# If no failures, set to 0 or use alternative approach
risk_cost_per_failure = 0
# Store results
risk_costs[month] = {
'equipment_capacity': equipment_capacity,
'capacity_lost_to_downtime': capacity_lost_to_downtime,
'monthly_risk_cost': monthly_risk_cost,
'failures_count': failures_count,
'risk_cost_per_failure': risk_cost_per_failure
}
# Add to array
risk_costs[month] = {'equipment_capacity': equipment_capacity, 'capacity_lost_to_downtime': capacity_lost_to_downtime, 'monthly_risk_cost': monthly_risk_cost, 'failures_count': failures_count, 'risk_cost_per_failure': risk_cost_per_failure}
risk_cost_array.append(risk_cost_per_failure)
return {'monthly_details': risk_costs, 'risk_cost_per_failure_array': risk_cost_array}
return {
'monthly_details': risk_costs,
'risk_cost_per_failure_array': risk_cost_array
}
# Example usage:
def get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price):
"""
Complete analysis combining monthly metrics with risk cost calculation
"""
# Get monthly metrics
monthly_metrics = analyze_monthly_metrics(timestamp_outs)
# Calculate risk costs
risk_analysis = calculate_risk_cost_per_failure(
monthly_metrics,
birnbaum_importance,
energy_price
)
# Combine results for comprehensive view
risk_analysis = calculate_risk_cost_per_failure(monthly_metrics, birnbaum_importance, energy_price)
combined_results = {}
for month in monthly_metrics.keys():
combined_results[month] = {
**monthly_metrics[month],
**risk_analysis['monthly_details'][month]
}
return {
'monthly_data': combined_results,
'risk_cost_array': risk_analysis['risk_cost_per_failure_array']
}
# Usage example:
# birnbaum_importance = 0.85 # Example value
# energy_price = 100 # Example: $100 per unit
#
# results = get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price)
# risk_cost_array = results['risk_cost_array']
# print("Risk cost per failure each month:", risk_cost_array)
combined_results[month] = {**monthly_metrics[month], **risk_analysis['monthly_details'][month]}
return {'monthly_data': combined_results, 'risk_cost_array': risk_analysis['risk_cost_per_failure_array']}

@ -1,100 +1,64 @@
import base64
import logging
import os
from typing import List
from urllib import parse
from pydantic import BaseModel
from starlette.config import Config
from starlette.datastructures import CommaSeparatedStrings
log = logging.getLogger(__name__)
class BaseConfigurationModel(BaseModel):
"""Base configuration model used by all config options."""
pass
def get_env_tags(tag_list: List[str]) -> dict:
"""Create dictionary of available env tags."""
tags = {}
for t in tag_list:
tag_key, env_key = t.split(":")
tag_key, env_key = t.split(':')
env_value = os.environ.get(env_key)
if env_value:
tags.update({tag_key: env_value})
return tags
def get_config():
try:
# Try to load from .env file first
config = Config(".env")
config = Config('.env')
except FileNotFoundError:
# If .env doesn't exist, use environment variables
config = Config(environ=os.environ)
return config
config = get_config()
LOG_LEVEL = config("LOG_LEVEL", default="INFO")
SERVICE_NAME = config("SERVICE_NAME", default="Be-OptimumOH")
ENV = config("ENV")
PORT = config("PORT", cast=int)
HOST = config("HOST")
# database
DATABASE_HOSTNAME = config("DATABASE_HOSTNAME")
_DATABASE_CREDENTIAL_USER = config("DATABASE_CREDENTIAL_USER")
_DATABASE_CREDENTIAL_PASSWORD = config("DATABASE_CREDENTIAL_PASSWORD")
LOG_LEVEL = config('LOG_LEVEL', default='INFO')
SERVICE_NAME = config('SERVICE_NAME', default='Be-OptimumOH')
ENV = config('ENV')
PORT = config('PORT', cast=int)
HOST = config('HOST')
DATABASE_HOSTNAME = config('DATABASE_HOSTNAME')
_DATABASE_CREDENTIAL_USER = config('DATABASE_CREDENTIAL_USER')
_DATABASE_CREDENTIAL_PASSWORD = config('DATABASE_CREDENTIAL_PASSWORD')
_QUOTED_DATABASE_PASSWORD = parse.quote(str(_DATABASE_CREDENTIAL_PASSWORD))
DATABASE_NAME = config("DATABASE_NAME")
DATABASE_PORT = config("DATABASE_PORT")
DATABASE_ENGINE_POOL_SIZE = config("DATABASE_ENGINE_POOL_SIZE", cast=int, default=20)
DATABASE_ENGINE_MAX_OVERFLOW = config(
"DATABASE_ENGINE_MAX_OVERFLOW", cast=int, default=0
)
COLLECTOR_HOSTNAME = config("COLLECTOR_HOSTNAME")
COLLECTOR_PORT = config("COLLECTOR_PORT", cast=int)
COLLECTOR_CREDENTIAL_USER = config("COLLECTOR_CREDENTIAL_USER")
COLLECTOR_CREDENTIAL_PASSWORD = config("COLLECTOR_CREDENTIAL_PASSWORD")
DATABASE_NAME = config('DATABASE_NAME')
DATABASE_PORT = config('DATABASE_PORT')
DATABASE_ENGINE_POOL_SIZE = config('DATABASE_ENGINE_POOL_SIZE', cast=int, default=20)
DATABASE_ENGINE_MAX_OVERFLOW = config('DATABASE_ENGINE_MAX_OVERFLOW', cast=int, default=0)
COLLECTOR_HOSTNAME = config('COLLECTOR_HOSTNAME')
COLLECTOR_PORT = config('COLLECTOR_PORT', cast=int)
COLLECTOR_CREDENTIAL_USER = config('COLLECTOR_CREDENTIAL_USER')
COLLECTOR_CREDENTIAL_PASSWORD = config('COLLECTOR_CREDENTIAL_PASSWORD')
QUOTED_COLLECTOR_CREDENTIAL_PASSWORD = parse.quote(str(COLLECTOR_CREDENTIAL_PASSWORD))
COLLECTOR_NAME = config("COLLECTOR_NAME")
# Deal w
SQLALCHEMY_DATABASE_URI = f"postgresql+asyncpg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}"
SQLALCHEMY_COLLECTOR_URI = f"postgresql+asyncpg://{COLLECTOR_CREDENTIAL_USER}:{QUOTED_COLLECTOR_CREDENTIAL_PASSWORD}@{COLLECTOR_HOSTNAME}:{COLLECTOR_PORT}/{COLLECTOR_NAME}"
TIMEZONE = config("TIMEZONE")
MAXIMO_BASE_URL = config("MAXIMO_BASE_URL")
MAXIMO_API_KEY = config("MAXIMO_API_KEY")
AUTH_SERVICE_API = config("AUTH_SERVICE_API")
REALIBILITY_SERVICE_API = config("REALIBILITY_SERVICE_API")
RBD_SERVICE_API = config("RBD_SERVICE_API")
TEMPORAL_URL = config("TEMPORAL_URL")
BASE_URL = config("BASE_URL")
API_KEY = config("API_KEY")
TR_RBD_ID = config("TR_RBD_ID")
TC_RBD_ID = config("TC_RBD_ID")
DEFAULT_TC_ID = config("DEFAULT_TC_ID")
REDIS_HOST = config("REDIS_HOST")
REDIS_PORT = config("REDIS_PORT", cast=int)
RATELIMIT_STORAGE_URI = f"redis://{REDIS_HOST}:{REDIS_PORT}"
REDIS_AUTH_DB = config("REDIS_AUTH_DB", cast=int)
COLLECTOR_NAME = config('COLLECTOR_NAME')
SQLALCHEMY_DATABASE_URI = f'postgresql+asyncpg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}'
SQLALCHEMY_COLLECTOR_URI = f'postgresql+asyncpg://{COLLECTOR_CREDENTIAL_USER}:{QUOTED_COLLECTOR_CREDENTIAL_PASSWORD}@{COLLECTOR_HOSTNAME}:{COLLECTOR_PORT}/{COLLECTOR_NAME}'
TIMEZONE = config('TIMEZONE')
MAXIMO_BASE_URL = config('MAXIMO_BASE_URL')
MAXIMO_API_KEY = config('MAXIMO_API_KEY')
AUTH_SERVICE_API = config('AUTH_SERVICE_API')
REALIBILITY_SERVICE_API = config('REALIBILITY_SERVICE_API')
RBD_SERVICE_API = config('RBD_SERVICE_API')
TEMPORAL_URL = config('TEMPORAL_URL')
BASE_URL = config('BASE_URL')
API_KEY = config('API_KEY')
TR_RBD_ID = config('TR_RBD_ID')
TC_RBD_ID = config('TC_RBD_ID')
DEFAULT_TC_ID = config('DEFAULT_TC_ID')
REDIS_HOST = config('REDIS_HOST')
REDIS_PORT = config('REDIS_PORT', cast=int)
RATELIMIT_STORAGE_URI = f'redis://{REDIS_HOST}:{REDIS_PORT}'
REDIS_AUTH_DB = config('REDIS_AUTH_DB', cast=int)

@ -1,25 +1,20 @@
from contextvars import ContextVar
from typing import Optional, Final
REQUEST_ID_CTX_KEY: Final[str] = "request_id"
USER_ID_CTX_KEY: Final[str] = "user_id"
USERNAME_CTX_KEY: Final[str] = "username"
ROLE_CTX_KEY: Final[str] = "role"
REQUEST_ID_CTX_KEY: Final[str] = 'request_id'
USER_ID_CTX_KEY: Final[str] = 'user_id'
USERNAME_CTX_KEY: Final[str] = 'username'
ROLE_CTX_KEY: Final[str] = 'role'
_request_id_ctx_var: ContextVar[Optional[str]] = ContextVar(REQUEST_ID_CTX_KEY, default=None)
_user_id_ctx_var: ContextVar[Optional[str]] = ContextVar(USER_ID_CTX_KEY, default=None)
_username_ctx_var: ContextVar[Optional[str]] = ContextVar(USERNAME_CTX_KEY, default=None)
_role_ctx_var: ContextVar[Optional[str]] = ContextVar(ROLE_CTX_KEY, default=None)
def get_request_id() -> Optional[str]:
return _request_id_ctx_var.get()
def set_request_id(request_id: str):
return _request_id_ctx_var.set(request_id)
def reset_request_id(token):
_request_id_ctx_var.reset(token)

@ -1,18 +1,12 @@
import json
import logging
from typing import Dict, Union, Tuple
from typing import Dict, Union
from decimal import Decimal, getcontext
import math
# Set high precision for decimal calculations
getcontext().prec = 50
Structure = Union[str, Dict[str, list]]
log = logging.getLogger(__name__)
def prod(iterable):
"""Compute product of all elements in iterable with high precision."""
result = Decimal('1.0')
for x in iterable:
if isinstance(x, (int, float)):
@ -20,56 +14,41 @@ def prod(iterable):
result *= x
return float(result)
def system_availability(structure: Structure, availabilities: Dict[str, float]) -> float:
"""Recursively compute system availability with precise calculations."""
if isinstance(structure, str): # base case - component
if isinstance(structure, str):
if structure not in availabilities:
raise ValueError(f"Component '{structure}' not found in availabilities")
return float(Decimal(str(availabilities[structure])))
if isinstance(structure, dict):
if "series" in structure:
components = structure["series"]
if not components: # Handle empty series
if 'series' in structure:
components = structure['series']
if not components:
return 1.0
# Series: A_system = A1 * A2 * ... * An
product = Decimal('1.0')
for s in components:
availability = system_availability(s, availabilities)
product *= Decimal(str(availability))
return float(product)
elif "parallel" in structure:
components = structure["parallel"]
if not components: # Handle empty parallel
if 'parallel' in structure:
components = structure['parallel']
if not components:
return 0.0
# Parallel: A_system = 1 - (1-A1) * (1-A2) * ... * (1-An)
product = Decimal('1.0')
for s in components:
availability = system_availability(s, availabilities)
unavailability = Decimal('1.0') - Decimal(str(availability))
product *= unavailability
result = Decimal('1.0') - product
return float(result)
elif "parallel_no_redundancy" in structure:
# Load sharing - system availability is minimum of components
components = structure["parallel_no_redundancy"]
if 'parallel_no_redundancy' in structure:
components = structure['parallel_no_redundancy']
if not components:
return 0.0
availabilities_list = [system_availability(s, availabilities) for s in components]
return min(availabilities_list)
raise ValueError(f"Invalid structure definition: {structure}")
raise ValueError(f'Invalid structure definition: {structure}')
def get_all_components(structure: Structure) -> set:
"""Extract all component names from a structure."""
components = set()
def extract_components(substructure):
@ -79,131 +58,59 @@ def get_all_components(structure: Structure) -> set:
for component_list in substructure.values():
for component in component_list:
extract_components(component)
extract_components(structure)
return components
def birnbaum_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float:
"""
Calculate Birnbaum importance for a component.
Birnbaum importance = A_system/A_component
This is approximated as:
I_B = A_system(A_i=1) - A_system(A_i=0)
Where A_i is the availability of component i.
"""
# Create copies for calculations
avail_up = availabilities.copy()
avail_down = availabilities.copy()
# Set component availability to 1 (perfect)
avail_up[component] = 1.0
# Set component availability to 0 (failed)
avail_down[component] = 0.0
# Calculate system availability in both cases
system_up = system_availability(structure, avail_up)
system_down = system_availability(structure, avail_down)
# Birnbaum importance is the difference
return system_up - system_down
def criticality_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float:
"""
Calculate Criticality importance for a component.
Criticality importance = Birnbaum importance * (1 - A_component) / (1 - A_system)
This represents the probability that component i is critical to system failure.
"""
birnbaum = birnbaum_importance(structure, availabilities, component)
system_avail = system_availability(structure, availabilities)
component_avail = availabilities[component]
if system_avail >= 1.0: # Perfect system
if system_avail >= 1.0:
return 0.0
criticality = birnbaum * (1.0 - component_avail) / (1.0 - system_avail)
return criticality
return birnbaum * (1.0 - component_avail) / (1.0 - system_avail)
def fussell_vesely_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float:
"""
Calculate Fussell-Vesely importance for a component.
FV importance = (A_system - A_system(A_i=0)) / A_system
This represents the fractional decrease in system availability when component i fails.
"""
system_avail = system_availability(structure, availabilities)
if system_avail <= 0.0:
return 0.0
# Calculate system availability with component failed
avail_down = availabilities.copy()
avail_down[component] = 0.0
system_down = system_availability(structure, avail_down)
fv = (system_avail - system_down) / system_avail
return fv
return (system_avail - system_down) / system_avail
def compute_all_importance_measures(structure: Structure, availabilities: Dict[str, float]) -> Dict[str, Dict[str, float]]:
"""
Compute all importance measures for each component.
Returns:
Dictionary with component names as keys and importance measures as values
"""
# Normalize availabilities to 0-1 range if needed
normalized_availabilities = {}
for k, v in availabilities.items():
if v > 1.0:
normalized_availabilities[k] = v / 100.0
else:
normalized_availabilities[k] = v
# Clamp to valid range [0, 1]
normalized_availabilities[k] = max(0.0, min(1.0, normalized_availabilities[k]))
# Get all components in the system
all_components = get_all_components(structure)
# Check for missing components
missing_components = all_components - set(normalized_availabilities.keys())
if missing_components:
log.warning(f"Missing components (assuming 100% availability): {missing_components}")
log.warning(f'Missing components (assuming 100% availability): {missing_components}')
for comp in missing_components:
normalized_availabilities[comp] = 1.0
# Calculate system baseline availability
system_avail = system_availability(structure, normalized_availabilities)
# Calculate importance measures for each component
results = {}
total_birnbaum = 0.0
for component in all_components:
if component in normalized_availabilities:
birnbaum = birnbaum_importance(structure, normalized_availabilities, component)
criticality = criticality_importance(structure, normalized_availabilities, component)
fv = fussell_vesely_importance(structure, normalized_availabilities, component)
results[component] = {
'birnbaum_importance': birnbaum,
'criticality_importance': criticality,
'fussell_vesely_importance': fv,
'component_availability': normalized_availabilities[component]
}
results[component] = {'birnbaum_importance': birnbaum, 'criticality_importance': criticality, 'fussell_vesely_importance': fv, 'component_availability': normalized_availabilities[component]}
total_birnbaum += birnbaum
# Calculate contribution percentages based on Birnbaum importance
if total_birnbaum > 0:
for component in results:
contribution_pct = results[component]['birnbaum_importance'] / total_birnbaum
@ -211,76 +118,29 @@ def compute_all_importance_measures(structure: Structure, availabilities: Dict[s
else:
for component in results:
results[component]['contribution_percentage'] = 0.0
# Add system-level information
results['_system_info'] = {
'system_availability': system_avail,
'system_unavailability': 1.0 - system_avail,
'total_birnbaum_importance': total_birnbaum
}
results['_system_info'] = {'system_availability': system_avail, 'system_unavailability': 1.0 - system_avail, 'total_birnbaum_importance': total_birnbaum}
return results
def calculate_contribution_accurate(availabilities: Dict[str, float], structure_file: str='src/overhaul/rbd_structure.json') -> Dict[str, Dict[str, float]]:
"""
Calculate component contributions using proper importance measures.
Args:
availabilities: Dictionary of component availabilities
structure_file: Path to RBD structure JSON file
Returns:
Dictionary containing all importance measures and contributions
"""
try:
with open(structure_file, 'r') as model_file:
structure = json.load(model_file)
except FileNotFoundError:
raise FileNotFoundError(f"Structure file not found: {structure_file}")
raise FileNotFoundError(f'Structure file not found: {structure_file}')
except json.JSONDecodeError:
raise ValueError(f"Invalid JSON in structure file: {structure_file}")
# Compute all importance measures
raise ValueError(f'Invalid JSON in structure file: {structure_file}')
results = compute_all_importance_measures(structure, availabilities)
# Extract system information
system_info = results.pop('_system_info')
# Log results
log.info(f"System Availability: {system_info['system_availability']:.6f}")
log.info(f"System Unavailability: {system_info['system_unavailability']:.6f}")
# Sort components by Birnbaum importance (most critical first)
sorted_components = sorted(results.items(),
key=lambda x: x[1]['birnbaum_importance'],
reverse=True)
# print("\n=== COMPONENT IMPORTANCE ANALYSIS ===")
# print(f"System Availability: {system_info['system_availability']:.6f} ({system_info['system_availability']*100:.4f}%)")
# print(f"System Unavailability: {system_info['system_unavailability']:.6f}")
# print("\nComponent Rankings (by Birnbaum Importance):")
# print(f"{'Component':<20} {'Availability':<12} {'Birnbaum':<12} {'Criticality':<12} {'F-V':<12} {'Contribution%':<12}")
# print("-" * 92)
sorted_components = sorted(results.items(), key=lambda x: x[1]['birnbaum_importance'], reverse=True)
for component, measures in sorted_components:
print(f"{component:<20} {measures['component_availability']:<12.6f} "
f"{measures['birnbaum_importance']:<12.6f} {measures['criticality_importance']:<12.6f} "
f"{measures['fussell_vesely_importance']:<12.6f} {measures['contribution_percentage']*100:<12.4f}")
# Return results with system info included
# results['_system_info'] = system_info
print(f"{component:<20} {measures['component_availability']:<12.6f} {measures['birnbaum_importance']:<12.6f} {measures['criticality_importance']:<12.6f} {measures['fussell_vesely_importance']:<12.6f} {measures['contribution_percentage'] * 100:<12.4f}")
return results
# Legacy function for backwards compatibility
def calculate_contribution(availabilities):
"""Legacy function - redirects to improved version."""
try:
return calculate_contribution_accurate(availabilities)
except Exception as e:
log.error(f"Error in contribution calculation: {e}")
log.error(f'Error in contribution calculation: {e}')
raise

@ -1,125 +1,36 @@
"""
CSRF Token Verification Dependency for be-optimumoh (FastAPI).
Menggantikan CSRFProtectMiddleware (Double Submit Cookie) dengan verifikasi
berbasis Redis. Token CSRF di-generate oleh be-auth (CSRFTokenResource) dan
disimpan di Redis dalam bentuk SHA256 hash.
Token Flow (generation di be-auth, verification di sini):
GENERATION (be-auth / CSRFTokenResource):
1. token = secrets.token_urlsafe(32)
2. token_hash = SHA256(token)
3. redis[csrf:{user_id}:{path_hash}] = token_hash (dengan TTL)
4. return plain token ke client
VERIFICATION (dependency ini):
1. Baca X-CSRF-Token header dari request
2. Ambil user_id dari request.state.user (di-set oleh JWTBearer)
3. incoming_hash = SHA256(nilai X-CSRF-Token)
4. stored_hash = redis_auth.get("csrf:{user_id}:{path_hash}")
5. secrets.compare_digest(incoming_hash, stored_hash)
6. redis_auth.delete(key) one-time use
NOTE: Dependency ini harus selalu dikombinasikan dengan JWTBearer() agar
request.state.user sudah tersedia saat dijalankan.
Usage:
from fastapi import Depends
from src.csrf_protect import csrf_protect
@router.post("/some-endpoint", dependencies=[Depends(csrf_protect)])
async def some_endpoint(...):
...
"""
import hashlib
import logging
import secrets
import redis
from fastapi import HTTPException, Request
import src.config as config
from src.context import get_user_id
log = logging.getLogger(__name__)
# Koneksi Redis untuk CSRF — Redis DB 0 tempat be-auth menyimpan CSRF token
_redis_auth = redis.Redis(
host=config.REDIS_HOST,
port=int(config.REDIS_PORT),
db=config.REDIS_AUTH_DB,
)
_redis_auth = redis.Redis(host=config.REDIS_HOST, port=int(config.REDIS_PORT), db=config.REDIS_AUTH_DB)
async def csrf_protect(request: Request) -> None:
"""
FastAPI dependency untuk verifikasi CSRF token berbasis Redis.
Harus dikombinasikan dengan JWTBearer() dependency.
"""
# ── Step 1: ambil user_id dari request.state (di-set oleh JWTBearer) ────
user = getattr(request.state, "user", None)
user = getattr(request.state, 'user', None)
user_id = str(user.user_id) if user else get_user_id()
if not user_id:
raise HTTPException(status_code=401, detail="Authentication required")
# ── Step 2: baca CSRF token dari request header ───────────────────────────
# Starlette lowercases all header names
csrf_token_header = (
request.headers.get("x-csrf-token")
or request.headers.get("x-xsrf-token")
or request.headers.get("x-csrf-token")
)
raise HTTPException(status_code=401, detail='Authentication required')
csrf_token_header = request.headers.get('x-csrf-token') or request.headers.get('x-xsrf-token') or request.headers.get('x-csrf-token')
if not csrf_token_header:
log.warning(
"[CSRF] Missing X-CSRF-Token header | user_id=%s | IP: %s",
user_id,
request.client.host if request.client else "unknown",
)
raise HTTPException(status_code=403, detail="CSRF token missing in header")
log.warning('[CSRF] Missing X-CSRF-Token header | user_id=%s | IP: %s', user_id, request.client.host if request.client else 'unknown')
raise HTTPException(status_code=403, detail='CSRF token missing in header')
csrf_token_header = csrf_token_header.strip()
# ── Step 3: bangun Redis key (harus sama dengan format di be-auth) ─────────
current_path = request.url.path
path_hash = hashlib.sha256(current_path.encode()).hexdigest()[:16]
redis_key = f"csrf:{user_id}:{path_hash}"
# ── Step 4: ambil stored token hash dari Redis (DB 0) ────────────────────
redis_key = f'csrf:{user_id}:{path_hash}'
stored_token_hash = _redis_auth.get(redis_key)
if not stored_token_hash:
log.warning(
"[CSRF] Token not found in Redis | user_id=%s | path=%s | IP: %s",
user_id,
current_path,
request.client.host if request.client else "unknown",
)
raise HTTPException(status_code=403, detail="CSRF token expired or invalid")
log.warning('[CSRF] Token not found in Redis | user_id=%s | path=%s | IP: %s', user_id, current_path, request.client.host if request.client else 'unknown')
raise HTTPException(status_code=403, detail='CSRF token expired or invalid')
if isinstance(stored_token_hash, bytes):
stored_token_hash = stored_token_hash.decode("utf-8")
# ── Step 5: bandingkan hash dengan constant-time digest ───────────────────
stored_token_hash = stored_token_hash.decode('utf-8')
incoming_hash = hashlib.sha256(csrf_token_header.encode()).hexdigest()
if not secrets.compare_digest(incoming_hash, stored_token_hash):
log.warning(
"[CSRF] Token mismatch | user_id=%s | path=%s | IP: %s",
user_id,
current_path,
request.client.host if request.client else "unknown",
)
log.warning('[CSRF] Token mismatch | user_id=%s | path=%s | IP: %s', user_id, current_path, request.client.host if request.client else 'unknown')
_redis_auth.delete(redis_key)
raise HTTPException(status_code=403, detail="CSRF token verification failed")
# ── Step 6: hapus token (one-time use) dan lanjutkan ─────────────────────
raise HTTPException(status_code=403, detail='CSRF token verification failed')
_redis_auth.delete(redis_key)
log.info(
"[CSRF] Verified & deleted | user_id=%s | path=%s",
user_id,
current_path,
)
log.info('[CSRF] Verified & deleted | user_id=%s | path=%s', user_id, current_path)

@ -1,66 +1,44 @@
# src/database.py
import re
import operator
from contextlib import asynccontextmanager
from typing import Annotated, Any
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import DeclarativeBase
from starlette.requests import Request
from src.config import SQLALCHEMY_DATABASE_URI, SQLALCHEMY_COLLECTOR_URI
engine = create_async_engine(SQLALCHEMY_DATABASE_URI, echo=False, future=True)
collector_engine = create_async_engine(SQLALCHEMY_COLLECTOR_URI, echo=False, future=True)
async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async_collector_session = async_sessionmaker(
collector_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False)
async_collector_session = async_sessionmaker(collector_engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False)
async def get_collector_db(request: Request):
return request.state.collector_db
def get_db(request: Request):
return request.state.db
DbSession = Annotated[AsyncSession, Depends(get_db)]
CollectorDbSession = Annotated[AsyncSession, Depends(get_collector_db)]
class Base(DeclarativeBase):
@declared_attr.directive
def __tablename__(cls) -> str:
return resolve_table_name(cls.__name__)
def dict(self):
"""Returns a dict representation of a model."""
if hasattr(self, '__table__'):
return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns}
return {}
class CollectorBase(DeclarativeBase):
@declared_attr.directive
def __tablename__(cls) -> str:
return resolve_table_name(cls.__name__)
def dict(self):
"""Returns a dict representation of a model."""
if hasattr(self, '__table__'):
return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns}
return {}
@ -90,21 +68,16 @@ async def get_collector_session():
await session.close()
def resolve_table_name(name):
"""Resolves table names to their mapped names."""
names = re.split("(?=[A-Z])", name) # noqa
return "_".join([x.lower() for x in names if x])
names = re.split('(?=[A-Z])', name)
return '_'.join([x.lower() for x in names if x])
def get_class_by_tablename(table_fullname: str) -> Any:
"""Return class reference mapped to table."""
def _find_class(name):
for c in Base.registry._class_registry.values():
if hasattr(c, "__table__"):
if hasattr(c, '__table__'):
if c.__table__.fullname.lower() == name.lower():
return c
return None
mapped_name = resolve_table_name(table_fullname)
mapped_class = _find_class(mapped_name)
return mapped_class
return _find_class(mapped_name)

@ -1,22 +1,18 @@
from typing import List, Optional
from pydantic import Field
from src.models import DefultBase
class CommonParams(DefultBase):
# This ensures no extra query params are allowed
current_user: Optional[str] = Field(None, alias="currentUser")
page: int = Field(1, gt=0, lt=2147483647)
items_per_page: int = Field(5, gt=0, le=50, multiple_of=5, alias="itemsPerPage")
query_str: Optional[str] = Field(None, alias="q")
filter_spec: Optional[str] = Field(None, alias="filter")
sort_by: List[str] = Field(default_factory=list, alias="sortBy[]")
descending: List[bool] = Field(default_factory=list, alias="descending[]")
exclude: List[str] = Field(default_factory=list, alias="exclude[]")
all_params: int = Field(0, alias="all")
current_user: Optional[str] = Field(None, alias='currentUser')
page: int = Field(1, gt=0, le=1000000)
items_per_page: int = Field(5, gt=0, le=50, multiple_of=5, alias='itemsPerPage')
query_str: Optional[str] = Field(None, alias='q')
filter_spec: Optional[str] = Field(None, alias='filter')
sort_by: List[str] = Field(default_factory=list, alias='sortBy[]')
descending: List[bool] = Field(default_factory=list, alias='descending[]')
exclude: List[str] = Field(default_factory=list, alias='exclude[]')
all_params: int = Field(0, alias='all', ge=0, le=1000000)
# Property to mirror your original return dict's bool conversion
@property
def is_all(self) -> bool:
return bool(self.all_params)

@ -1,149 +1,58 @@
import logging
from typing import Annotated, List, Type, TypeVar
from fastapi import Depends, Query
from pydantic.types import Json, constr
from sqlalchemy import Select, desc, func, or_
from sqlalchemy.exc import ProgrammingError
from sqlalchemy_filters import apply_pagination
from src.database.schema import CommonParams
from .core import DbSession
log = logging.getLogger(__name__)
QueryStr = constr(pattern='^[ -~]+$', min_length=1)
# allows only printable characters
QueryStr = constr(pattern=r"^[ -~]+$", min_length=1)
def common_parameters(
db_session: DbSession, # type: ignore
current_user: QueryStr = Query(None, alias="currentUser"), # type: ignore
page: int = Query(1, gt=0, lt=2147483647),
items_per_page: int = Query(5, alias="itemsPerPage", gt=-2, lt=2147483647),
query_str: QueryStr = Query(None, alias="q"), # type: ignore
filter_spec: QueryStr = Query(None, alias="filter"), # type: ignore
sort_by: List[str] = Query([], alias="sortBy[]"),
descending: List[bool] = Query([], alias="descending[]"),
exclude: List[str] = Query([], alias="exclude[]"),
all: int = Query(0),
# role: QueryStr = Depends(get_current_role),
):
return {
"db_session": db_session,
"page": page,
"items_per_page": items_per_page,
"query_str": query_str,
"filter_spec": filter_spec,
"sort_by": sort_by,
"descending": descending,
"current_user": current_user,
"all": bool(all),
# "role": role,
}
CommonParameters = Annotated[
dict[str, int | str | DbSession | QueryStr | Json | List[str] | List[bool]] | bool,
Depends(common_parameters),
]
T = TypeVar("T", bound=CommonParams)
def common_parameters(db_session: DbSession, current_user: QueryStr=Query(None, alias='currentUser'), page: int=Query(1, gt=0, le=1000000), items_per_page: int=Query(5, alias='itemsPerPage', gt=-2, le=1000000), query_str: QueryStr=Query(None, alias='q'), filter_spec: QueryStr=Query(None, alias='filter'), sort_by: List[str]=Query([], alias='sortBy[]'), descending: List[bool]=Query([], alias='descending[]'), exclude: List[str]=Query([], alias='exclude[]'), all: int=Query(0, ge=0, le=1000000)):
return {'db_session': db_session, 'page': page, 'items_per_page': items_per_page, 'query_str': query_str, 'filter_spec': filter_spec, 'sort_by': sort_by, 'descending': descending, 'current_user': current_user, 'all': bool(all)}
CommonParameters = Annotated[dict[str, int | str | DbSession | QueryStr | Json | List[str] | List[bool]] | bool, Depends(common_parameters)]
T = TypeVar('T', bound=CommonParams)
def get_params_factory(model_type: Type[T]):
async def wrapper(
db_session: DbSession,
params: Annotated[model_type, Query()] # type: ignore
):
async def wrapper(db_session: DbSession, params: Annotated[model_type, Query()]):
res = params.model_dump()
return {
"db_session": db_session,
"all": params.is_all,
**res
}
return {'db_session': db_session, 'all': params.is_all, **res}
return wrapper
def search(*, query_str: str, query: Query, model, sort=False):
"""Perform a search based on the query."""
search_model = model
if not query_str.strip():
return query
search = []
if hasattr(search_model, "search_vector"):
if hasattr(search_model, 'search_vector'):
vector = search_model.search_vector
search.append(vector.op("@@")(func.tsq_parse(query_str)))
if hasattr(search_model, "name"):
search.append(
search_model.name.ilike(f"%{query_str}%"),
)
search.append(vector.op('@@')(func.tsq_parse(query_str)))
if hasattr(search_model, 'name'):
search.append(search_model.name.ilike(f'%{query_str}%'))
search.append(search_model.name == query_str)
if not search:
raise Exception(f"Search not supported for model: {model}")
raise Exception(f'Search not supported for model: {model}')
query = query.filter(or_(*search))
if sort:
query = query.order_by(desc(func.ts_rank_cd(vector, func.tsq_parse(query_str))))
return query.params(term=query_str)
async def search_filter_sort_paginate(
db_session: DbSession,
model,
query_str: str = None,
filter_spec: str | dict | None = None,
page: int = 1,
items_per_page: int = 5,
sort_by: List[str] = None,
descending: List[bool] = None,
current_user: str = None,
exclude: List[str] = None,
all: bool = False,
):
"""Common functionality for searching, filtering, sorting, and pagination."""
# try:
# Check if model is Select
async def search_filter_sort_paginate(db_session: DbSession, model, query_str: str=None, filter_spec: str | dict | None=None, page: int=1, items_per_page: int=5, sort_by: List[str]=None, descending: List[bool]=None, current_user: str=None, exclude: List[str]=None, all: bool=False):
if not isinstance(model, Select):
query = Select(model)
else:
query = model
if query_str:
sort = False if sort_by else True
query = search(query_str=query_str, query=query, model=model, sort=sort)
# Get total count
count_query = Select(func.count()).select_from(query.subquery())
total = await db_session.scalar(count_query)
if all:
result = await db_session.execute(query)
items = result.scalars().all()
return {
"items": items,
"itemsPerPage": total,
"page": 1,
"total": total,
"totalPages": 1,
}
return {'items': items, 'itemsPerPage': total, 'page': 1, 'total': total, 'totalPages': 1}
query = query.offset((page - 1) * items_per_page).limit(items_per_page)
result = await db_session.execute(query)
items = result.scalars().all()
return {
"items": items,
"itemsPerPage": items_per_page,
"page": page,
"total": total,
"totalPages": (total + items_per_page - 1) // items_per_page,
}
return {'items': items, 'itemsPerPage': items_per_page, 'page': page, 'total': total, 'totalPages': (total + items_per_page - 1) // items_per_page}

@ -1,29 +1,12 @@
import sys
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
from strenum import StrEnum
class OptimumOHEnum(StrEnum):
"""
A custom Enum class that extends StrEnum.
This class inherits all functionality from StrEnum, including
string representation and automatic value conversion to strings.
Example:
class Visibility(DispatchEnum):
OPEN = "Open"
RESTRICTED = "Restricted"
assert str(Visibility.OPEN) == "Open"
"""
pass # No additional implementation needed
pass
class ResponseStatus(OptimumOHEnum):
SUCCESS = "success"
ERROR = "error"
SUCCESS = 'success'
ERROR = 'error'

@ -1,21 +1,12 @@
from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import UUID, Column, Float, ForeignKey, String
from sqlalchemy.orm import relationship
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
from src.models import DefaultMixin
class ScopeEquipmentPart(Base, DefaultMixin):
__tablename__ = "oh_ms_scope_equipment_part"
__tablename__ = 'oh_ms_scope_equipment_part'
required_stock = Column(Float, nullable=False, default=0)
sparepart_id = Column(UUID(as_uuid=True), ForeignKey("oh_ms_sparepart.id"), nullable=False)
sparepart_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_sparepart.id'), nullable=False)
location_tag = Column(String, nullable=False)
location_tag = Column(String, nullable=False)
part = relationship(
"MasterSparePart",
lazy="selectin",
)
part = relationship('MasterSparePart', lazy='selectin')

@ -1,28 +1,12 @@
from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends
from src.database.core import CollectorDbSession
from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (ScopeEquipmentActivityCreate,
ScopeEquipmentActivityPagination,
ScopeEquipmentActivityRead, ScopeEquipmentActivityUpdate)
from .service import get_all
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("/{location_tag}", response_model=StandardResponse[List[Dict]])
@router.get('/{location_tag}', response_model=StandardResponse[List[Dict]])
async def get_scope_equipment_parts(collector_db_session: CollectorDbSession, location_tag):
"""Get all scope activity pagination."""
# return
data = await get_all(db_session=collector_db_session, location_tag=location_tag)
return StandardResponse(
data=data,
message="Data retrieved successfully",
)
return StandardResponse(data=data, message='Data retrieved successfully')

@ -1,69 +1,21 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from typing import List, Optional
from pydantic import Field
from src.models import DefultBase, Pagination
class ScopeEquipmentActivityBase(DefultBase):
assetnum: str = Field(..., description="Assetnum is required")
assetnum: str = Field(..., description='Assetnum is required')
class ScopeEquipmentActivityCreate(ScopeEquipmentActivityBase):
name: str
cost: Optional[float] = Field(0)
cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
class ScopeEquipmentActivityUpdate(ScopeEquipmentActivityBase):
name: Optional[str] = Field(None)
cost: Optional[str] = Field(0)
class ScopeEquipmentActivityRead(ScopeEquipmentActivityBase):
name: str
cost: float
cost: float = Field(..., ge=0, le=1000000000000000)
class ScopeEquipmentActivityPagination(Pagination):
items: List[ScopeEquipmentActivityRead] = []
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,218 +1,29 @@
import random
from typing import Optional
from sqlalchemy import Delete, Select, and_, text
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from src.database.core import CollectorDbSession, DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate
from .model import ScopeEquipmentPart
from .schema import ScopeEquipmentActivityCreate, ScopeEquipmentActivityUpdate
# async def get(*, db_session: DbSession, scope_equipment_activity_id: str) -> Optional[ScopeEquipmentActivity]:
# """Returns a document based on the given document id."""
# result = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id)
# return result
from sqlalchemy import text
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession as DbSession
from sqlalchemy.sql import text
import logging
logger = logging.getLogger(__name__)
# async def get_all(
# db_session: CollectorDbSession,
# location_tag: Optional[str] = None,
# start_year: int = 2023,
# end_year: Optional[int] = None,
# parent_wonum: Optional[str] = None
# ) -> List[Dict[str, Any]]:
# """
# Retrieve overhaul spare parts consumption data.
# Handles missing data, null parent WO, and query safety.
# Args:
# db_session: Async SQLAlchemy session
# location_tag: Optional location filter
# start_year: Year to start analysis (default 2023)
# end_year: Optional year to end analysis (default start_year + 1)
# parent_wonum: Parent work order number (required for context)
# Returns:
# List of dictionaries with spare part usage per overhaul WO.
# """
# # --- 1. Basic validation ---
# if not parent_wonum:
# logger.warning("Parent WO number not provided. Returning empty result.")
# return []
# if start_year < 1900 or (end_year and end_year < start_year):
# raise ValueError("Invalid year range provided.")
# if end_year is None:
# end_year = start_year + 1
# # --- 2. Build SQL safely ---
# base_query = """
# WITH filtered_wo AS (
# SELECT wonum, location_tag
# FROM public.wo_max
# WHERE worktype = 'OH'
# AND xx_parent = :parent_wonum
# """
# params = {
# "parent_wonum": parent_wonum,
# }
# if location_tag:
# base_query += " AND location_tag = :location_tag"
# params["location_tag"] = location_tag
# base_query += """
# ),
# filtered_materials AS (
# SELECT wonum, itemnum, itemqty, inv_curbaltotal, inv_avgcost
# FROM public.wo_max_material
# WHERE wonum IN (SELECT wonum FROM filtered_wo)
# )
# SELECT
# fwo.location_tag AS location_tag,
# fm.itemnum,
# spl.description AS sparepart_name,
# COALESCE(SUM(fm.itemqty), 0) AS parts_consumed_in_oh,
# COALESCE(AVG(fm.inv_avgcost), 0) AS avgcost,
# COALESCE(AVG(fm.inv_curbaltotal), 0) AS inv_curbaltotal
# FROM filtered_wo fwo
# INNER JOIN filtered_materials fm ON fwo.wonum = fm.wonum
# LEFT JOIN public.maximo_sparepart_pr_po_line spl ON fm.itemnum = spl.item_num
# GROUP BY fwo.location_tag, fm.itemnum, spl.description
# ORDER BY fwo.location_tag, fm.itemnum;
# """
# # --- 3. Execute query ---
# try:
# result = await db_session.execute(text(base_query), params)
# rows = result.fetchall()
# # Handle "no data found"
# if not rows:
# logger.info(f"No spare part data found for parent WO {parent_wonum}.")
# return []
# # --- 4. Map results cleanly ---
# equipment_parts = []
# for row in rows:
# try:
# equipment_parts.append({
# "location_tag": row.location_tag,
# "itemnum": row.itemnum,
# "sparepart_name": row.sparepart_name or "-",
# "parts_consumed_in_oh": float(row.parts_consumed_in_oh or 0),
# "avgcost": float(row.avgcost or 0),
# "inv_curbaltotal": float(row.inv_curbaltotal or 0)
# })
# except Exception as parse_err:
# logger.error(f"Failed to parse row {row}: {parse_err}")
# continue # Skip malformed rows
# return equipment_parts
# except Exception as e:
# logger.exception(f"Database query failed: {e}")
# raise RuntimeError("Failed to fetch overhaul spare parts data.") from e
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import text
async def get_all(
db_session: AsyncSession,
location_tag: Optional[str] = None,
start_year: int = 2023,
end_year: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get overhaul spare parts consumption data with optimized query.
Args:
db_session: SQLAlchemy async database session
location_tag: Optional filter for location (asset_location)
start_year: Starting year (default: 2023)
end_year: Ending year (default: start_year + 1)
Returns:
List of dictionaries with spare parts consumption data
"""
# Set default end year
async def get_all(db_session: AsyncSession, location_tag: Optional[str]=None, start_year: int=2023, end_year: Optional[int]=None) -> List[Dict[str, Any]]:
if end_year is None:
end_year = start_year + 1
# Build query dynamically
query_str = """
WITH filtered_wo AS (
SELECT DISTINCT wonum, asset_location, asset_unit
FROM public.wo_maximo ma
WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030')
"""
query_str = "\n WITH filtered_wo AS (\n SELECT DISTINCT wonum, asset_location, asset_unit\n FROM public.wo_maximo ma\n WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030')\n "
params = {}
# Optional filter for location
if location_tag:
query_str += " AND asset_location = :location_tag"
params["location_tag"] = location_tag
query_str += """
),
filtered_materials AS (
SELECT
mat.wonum,
mat.itemnum,
mat.itemqty,
mat.inv_curbaltotal AS inv_curbaltotal,
mat.inv_avgcost AS inv_avgcost
FROM public.wo_maximo_material AS mat
WHERE mat.wonum IN (SELECT wonum FROM filtered_wo)
)
SELECT
fwo.asset_location AS location_tag,
ft.itemnum,
COALESCE(spl.description, 'Unknown') AS sparepart_name,
AVG(ft.itemqty) AS total_parts_used,
COALESCE(AVG(ft.inv_avgcost), 0) AS avg_cost,
COALESCE(AVG(ft.inv_curbaltotal), 0) AS avg_inventory_balance
FROM filtered_wo AS fwo
INNER JOIN filtered_materials AS ft
ON fwo.wonum = ft.wonum
LEFT JOIN public.maximo_sparepart_pr_po_line AS spl
ON ft.itemnum = spl.item_num
GROUP BY fwo.asset_location, ft.itemnum, spl.description
ORDER BY fwo.asset_location, ft.itemnum;
"""
query_str += ' AND asset_location = :location_tag'
params['location_tag'] = location_tag
query_str += "\n ),\n filtered_materials AS (\n SELECT \n mat.wonum,\n mat.itemnum,\n mat.itemqty,\n mat.inv_curbaltotal AS inv_curbaltotal,\n mat.inv_avgcost AS inv_avgcost\n FROM public.wo_maximo_material AS mat\n WHERE mat.wonum IN (SELECT wonum FROM filtered_wo)\n )\n SELECT \n fwo.asset_location AS location_tag,\n ft.itemnum,\n COALESCE(spl.description, 'Unknown') AS sparepart_name,\n AVG(ft.itemqty) AS total_parts_used,\n COALESCE(AVG(ft.inv_avgcost), 0) AS avg_cost,\n COALESCE(AVG(ft.inv_curbaltotal), 0) AS avg_inventory_balance\n FROM filtered_wo AS fwo\n INNER JOIN filtered_materials AS ft \n ON fwo.wonum = ft.wonum\n LEFT JOIN public.maximo_sparepart_pr_po_line AS spl \n ON ft.itemnum = spl.item_num\n GROUP BY fwo.asset_location, ft.itemnum, spl.description\n ORDER BY fwo.asset_location, ft.itemnum;\n "
try:
result = await db_session.execute(text(query_str), params)
rows = result.fetchall()
equipment_parts = []
for row in rows:
equipment_parts.append({
"location_tag": row.location_tag,
"itemnum": row.itemnum,
"sparepart_name": row.sparepart_name,
"parts_consumed_in_oh": float(row.total_parts_used or 0),
"avg_cost": float(row.avg_cost or 0),
"inv_curbaltotal": float(row.avg_inventory_balance or 0),
})
equipment_parts.append({'location_tag': row.location_tag, 'itemnum': row.itemnum, 'sparepart_name': row.sparepart_name, 'parts_consumed_in_oh': float(row.total_parts_used or 0), 'avg_cost': float(row.avg_cost or 0), 'inv_curbaltotal': float(row.avg_inventory_balance or 0)})
return equipment_parts
except Exception as e:
print(f"[get_all] Database query error: {e}")
print(f'[get_all] Database query error: {e}')
raise

@ -1,22 +1,11 @@
from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy import UUID, Column, ForeignKey, String
from sqlalchemy.orm import relationship
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
from src.models import DefaultMixin
class EquipmentWorkscopeGroup(Base, DefaultMixin):
__tablename__ = "oh_tr_equipment_workscope_group"
__tablename__ = 'oh_tr_equipment_workscope_group'
workscope_group_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_workscope_group.id'))
location_tag = Column(String, nullable=False)
workscope_group = relationship("MasterActivity", lazy="selectin", back_populates="equipment_workscope_groups")
equipment = relationship(
"StandardScope",
lazy="raise",
primaryjoin="and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)
workscope_group = relationship('MasterActivity', lazy='selectin', back_populates='equipment_workscope_groups')
equipment = relationship('StandardScope', lazy='raise', primaryjoin='and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))', uselist=False)

@ -1,51 +1,22 @@
from typing import Dict, List, Optional
from fastapi import APIRouter, HTTPException, Query, status
from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from typing import Optional
from fastapi import APIRouter, Query
from src.database.service import CommonParameters, DbSession
from src.models import StandardResponse
from .schema import ScopeEquipmentJobCreate, ScopeEquipmentJobPagination
from .service import create, delete, get_all
router = APIRouter()
@router.get(
"/{location_tag}", response_model=StandardResponse[ScopeEquipmentJobPagination]
)
@router.get('/{location_tag}', response_model=StandardResponse[ScopeEquipmentJobPagination])
async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str]=Query(None)):
"""Get all scope pagination."""
# return
results = await get_all(common=common, location_tag=location_tag, scope=scope)
return StandardResponse(data=results, message='Data retrieved successfully')
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.post("/{assetnum}", response_model=StandardResponse[None])
async def create_scope_equipment_jobs(
db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate
):
"""Get all scope activity pagination."""
# return
@router.post('/{assetnum}', response_model=StandardResponse[None])
async def create_scope_equipment_jobs(db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate):
await create(db_session=db_session, assetnum=assetnum, scope_job_in=scope_job_in)
return StandardResponse(data=None, message='Data created successfully')
return StandardResponse(
data=None,
message="Data created successfully",
)
@router.post("/delete/{scope_job_id}", response_model=StandardResponse[None])
@router.post('/delete/{scope_job_id}', response_model=StandardResponse[None])
async def delete_scope_equipment_job(db_session: DbSession, scope_job_id):
await delete(db_session=db_session, scope_job_id=scope_job_id)
return StandardResponse(
data=None,
message="Data deleted successfully",
)
return StandardResponse(data=None, message='Data deleted successfully')

@ -1,80 +1,32 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import Field
from src.workscope_group.schema import ActivityMasterRead
from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead
class ScopeEquipmentJobBase(DefultBase):
pass
class ScopeEquipmentJobCreate(ScopeEquipmentJobBase):
job_ids: Optional[List[UUID]] = []
class ScopeEquipmentJobUpdate(ScopeEquipmentJobBase):
name: Optional[str] = Field(None)
cost: Optional[str] = Field(0)
class OverhaulActivity(DefultBase):
id: UUID
overhaul_scope: ScopeRead
class OverhaulJob(DefultBase):
id: UUID
overhaul_activity: OverhaulActivity
class ScopeEquipmentJobRead(ScopeEquipmentJobBase):
id: UUID
workscope_group: Optional[ActivityMasterRead]
location_tag: str
class ScopeEquipmentJobPagination(Pagination):
items: List[ScopeEquipmentJobRead] = []
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,153 +1,46 @@
import random
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import Delete, Select, and_
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from sqlalchemy import Select
from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate
from src.overhaul_activity.model import OverhaulActivity
from src.database.service import search_filter_sort_paginate
from src.standard_scope.model import MasterEquipment
from src.standard_scope.service import get_equipment_level_by_no
from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.model import MaintenanceType
from .model import EquipmentWorkscopeGroup
from .schema import ScopeEquipmentJobCreate
from src.soft_delete import apply_not_deleted_filter
# async def get(*, db_session: DbSession, scope_equipment_activity_id: str) -> Optional[ScopeEquipmentActivity]:
# """Returns a document based on the given document id."""
# result = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id)
# return result
# async def get_all(db_session: DbSession, assetnum: Optional[str], common):
# # Example usage
# if not assetnum:
# raise ValueError("assetnum parameter is required")
# # First get the parent equipment
# equipment_stmt = Select(MasterEquipment).where(MasterEquipment.assetnum == assetnum)
# equipment: MasterEquipment = await db_session.scalar(equipment_stmt)
# if not equipment:
# raise ValueError(f"No equipment found with assetnum: {assetnum}")
# # Build query for parts
# stmt = (
# Select(ScopeEquipmentJob)
# .where(ScopeEquipmentJob.assetnum == assetnum)
# .options(
# selectinload(ScopeEquipmentJob.job),
# selectinload(ScopeEquipmentJob.overhaul_jobs)
# .selectinload(OverhaulJob.overhaul_activity)
# .selectinload(OverhaulActivity.overhaul_scope),
# )
# )
# results = await search_filter_sort_paginate(model=stmt, **common)
# return results
async def get_all(*, common, location_tag: str, scope: Optional[str]=None):
"""Returns all documents."""
query = (
Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(None))
)
query = Select(EquipmentWorkscopeGroup).where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(None))
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup)
if scope:
query = (
query
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.filter(MaintenanceType.name == scope)
)
query = query.join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).filter(MaintenanceType.name == scope)
return await search_filter_sort_paginate(model=query, **common)
results = await search_filter_sort_paginate(model=query, **common)
return results
async def create(
*, db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate
):
async def create(*, db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate):
scope_jobs = []
if not assetnum:
raise ValueError("assetnum parameter is required")
# First get the parent equipment
raise ValueError('assetnum parameter is required')
equipment_stmt = Select(MasterEquipment).where(MasterEquipment.assetnum == assetnum)
equipment: MasterEquipment = await db_session.scalar(equipment_stmt)
if not equipment:
raise ValueError(f"No equipment found with assetnum: {assetnum}")
raise ValueError(f'No equipment found with assetnum: {assetnum}')
for job_id in scope_job_in.job_ids:
scope_equipment_job = ScopeEquipmentJob(assetnum=assetnum, job_id=job_id)
scope_jobs.append(scope_equipment_job)
db_session.add_all(scope_jobs)
await db_session.commit()
return
# async def update(*, db_session: DbSession, activity: ScopeEquipmentActivity, scope_equipment_activty_in: ScopeEquipmentActivityUpdate):
# """Updates a document."""
# data = scope_equipment_activty_in.model_dump()
# update_data = scope_equipment_activty_in.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(activity, field, update_data[field])
# await db_session.commit()
# return activity
async def delete(
*,
db_session: DbSession,
scope_job_id: int,
) -> bool:
"""
Deletes a scope job and returns success status.
Args:
db_session: Database session
scope_job_id: ID of the scope job to delete
user_id: ID of user performing the deletion
Returns:
bool: True if deletion was successful, False otherwise
Raises:
NotFoundException: If scope job doesn't exist
AuthorizationError: If user lacks delete permission
"""
async def delete(*, db_session: DbSession, scope_job_id: int) -> bool:
try:
# Check if job exists
scope_job = await db_session.get(ScopeEquipmentJob, scope_job_id)
if not scope_job:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
# Perform deletion
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
await db_session.delete(scope_job)
await db_session.commit()
return True
except Exception as e:
except Exception:
await db_session.rollback()
raise

@ -1,9 +1,7 @@
# Define base error model and exception hierarchy
import logging
import re
import uuid
from typing import Any, Dict, List, Optional
from asyncpg.exceptions import DataError as AsyncPGDataError
from fastapi import Request
from fastapi.exceptions import RequestValidationError
@ -12,82 +10,43 @@ from pydantic import BaseModel
from slowapi.errors import RateLimitExceeded
from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, SQLAlchemyError, NoResultFound
from starlette.exceptions import HTTPException as StarletteHTTPException
from src.enums import ResponseStatus
log = logging.getLogger(__name__)
# ── Custom exception classes ──────────────────────────────────────────────────
class OptimumOHBaseException(Exception):
def __init__(self, message: str, data: Any=None):
self.message = message
self.data = data
super().__init__(message)
class BadRequestError(OptimumOHBaseException):
pass
class BadRequestError(OptimumOHBaseException): pass
class UnauthorizedError(OptimumOHBaseException): pass
class ForbiddenError(OptimumOHBaseException): pass
class NotFoundError(OptimumOHBaseException): pass
class RequestTimeoutError(OptimumOHBaseException): pass
class UnprocessableEntityError(OptimumOHBaseException): pass
class TooManyRequestsError(OptimumOHBaseException): pass
class InternalServerError(OptimumOHBaseException): pass
class UnauthorizedError(OptimumOHBaseException):
pass
# ── Fixed client response messages (generic, not for logging) ─────────────────
class ForbiddenError(OptimumOHBaseException):
pass
_FIXED_MESSAGES = {
400: "Malformed JSON body input",
401: "Invalid authorization token",
403: "Forbidden Access",
404: "Resource not found",
408: "Request timeout",
422: "Invalid request parameter",
429: "Please try again later",
}
class NotFoundError(OptimumOHBaseException):
pass
# Human-readable error types for logging (no underscores)
_ERROR_TYPE_MAP = {
BadRequestError: "Bad Request",
UnauthorizedError: "Unauthorized",
ForbiddenError: "Forbidden",
NotFoundError: "Not Found",
RequestTimeoutError: "Request Timeout",
UnprocessableEntityError: "Unprocessable Entity",
TooManyRequestsError: "Too Many Requests",
InternalServerError: "Internal Server Error",
}
class RequestTimeoutError(OptimumOHBaseException):
pass
_EXCEPTION_STATUS_MAP = {
BadRequestError: 400,
UnauthorizedError: 401,
ForbiddenError: 403,
NotFoundError: 404,
RequestTimeoutError: 408,
UnprocessableEntityError: 422,
TooManyRequestsError: 429,
InternalServerError: 500,
}
class UnprocessableEntityError(OptimumOHBaseException):
pass
_HTTP_STATUS_NAMES = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
408: "Request Timeout",
409: "Conflict",
413: "Payload Too Large",
414: "URI Too Long",
415: "Unsupported Media Type",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
}
class TooManyRequestsError(OptimumOHBaseException):
pass
class InternalServerError(OptimumOHBaseException):
pass
_FIXED_MESSAGES = {400: 'Malformed JSON body input', 401: 'Invalid authorization token', 403: 'Forbidden Access', 404: 'Resource not found', 408: 'Request timeout', 422: 'Invalid request parameter', 429: 'Please try again later'}
_ERROR_TYPE_MAP = {BadRequestError: 'Bad Request', UnauthorizedError: 'Unauthorized', ForbiddenError: 'Forbidden', NotFoundError: 'Not Found', RequestTimeoutError: 'Request Timeout', UnprocessableEntityError: 'Unprocessable Entity', TooManyRequestsError: 'Too Many Requests', InternalServerError: 'Internal Server Error'}
_EXCEPTION_STATUS_MAP = {BadRequestError: 400, UnauthorizedError: 401, ForbiddenError: 403, NotFoundError: 404, RequestTimeoutError: 408, UnprocessableEntityError: 422, TooManyRequestsError: 429, InternalServerError: 500}
_HTTP_STATUS_NAMES = {400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 408: 'Request Timeout', 409: 'Conflict', 413: 'Payload Too Large', 414: 'URI Too Long', 415: 'Unsupported Media Type', 422: 'Unprocessable Entity', 429: 'Too Many Requests', 500: 'Internal Server Error'}
class ErrorDetail(BaseModel):
field: Optional[str] = None
@ -95,283 +54,188 @@ class ErrorDetail(BaseModel):
code: Optional[str] = None
params: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel):
data: Optional[Any] = None
message: str
status: ResponseStatus = ResponseStatus.ERROR
errors: Optional[List[ErrorDetail]] = None
# ── Helpers ───────────────────────────────────────────────────────────────────
def get_request_context(request: Request) -> dict:
"""Get detailed request context for logging."""
def get_client_ip() -> str:
if "X-Real-IP" in request.headers:
return request.headers["X-Real-IP"]
if "X-Forwarded-For" in request.headers:
return request.headers["X-Forwarded-For"].split(",")[0].strip()
return request.client.host if request.client else "unknown"
return {
"endpoint": request.url.path,
"url": str(request.url),
"method": request.method,
"remote_addr": get_client_ip(),
}
if 'X-Real-IP' in request.headers:
return request.headers['X-Real-IP']
if 'X-Forwarded-For' in request.headers:
return request.headers['X-Forwarded-For'].split(',')[0].strip()
return request.client.host if request.client else 'unknown'
return {'endpoint': request.url.path, 'url': str(request.url), 'method': request.method, 'remote_addr': get_client_ip()}
def _json_response(status_code: int, message: str, data: Any=None) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={"data": data, "message": message, "status": ResponseStatus.ERROR},
)
return JSONResponse(status_code=status_code, content={'data': data, 'message': message, 'status': ResponseStatus.ERROR})
def _store_log_state(
request: Request,
status_code: int,
error_id: str,
error_type: str,
error_message: str,
) -> None:
"""Store logging metadata on request.state so db_session_middleware emits a single structured log."""
def _store_log_state(request: Request, status_code: int, error_id: str, error_type: str, error_message: str) -> None:
request.state.log_status_code = status_code
request.state.log_error_id = error_id
request.state.log_error_type = error_type
request.state.log_error_message = error_message
def _short_db_error(orig_str: str) -> str:
"""Extract a concise, human-readable reason from a raw DB/asyncpg error string."""
s = orig_str.lower()
if "uuid" in s:
if "length must be between" in s or "got " in s:
return "Invalid UUID length"
if "badly formed" in s or "invalid hexadecimal" in s or "invalid uuid" in s:
return "Invalid UUID format"
if "invalid input syntax for type" in s:
m = re.search(r"invalid input syntax for type (\w+)", s)
type_name = m.group(1) if m else "value"
return f"Invalid format for type {type_name}"
if "invalid input for query argument" in s:
m = re.search(r"\(([^)]+)\)\s*$", orig_str)
if 'uuid' in s:
if 'length must be between' in s or 'got ' in s:
return 'Invalid UUID length'
if 'badly formed' in s or 'invalid hexadecimal' in s or 'invalid uuid' in s:
return 'Invalid UUID format'
if 'invalid input syntax for type' in s:
m = re.search('invalid input syntax for type (\\w+)', s)
type_name = m.group(1) if m else 'value'
return f'Invalid format for type {type_name}'
if 'invalid input for query argument' in s:
m = re.search('\\(([^)]+)\\)\\s*$', orig_str)
if m:
return _short_db_error(m.group(1))
return "Invalid query argument value"
if "unique constraint" in s:
return "Unique constraint violation"
if "foreign key constraint" in s:
return "Foreign key constraint violation"
if "null value in column" in s:
m = re.search(r'null value in column "([^"]+)"', orig_str)
col = f' "{m.group(1)}"' if m else ""
return f"Required field{col} is missing"
if "check constraint" in s:
return "Check constraint violation"
if "out of range" in s:
return "Value out of allowed range"
if "numeric field overflow" in s:
return "Numeric field overflow"
first_line = orig_str.split("\n")[0].split(". ")[0].strip()
first_line = re.sub(r"^<class '[^']+'>:\s*", "", first_line)
return first_line if first_line else "Database error"
# ── Core error response builder ───────────────────────────────────────────────
return 'Invalid query argument value'
if 'unique constraint' in s:
return 'Unique constraint violation'
if 'foreign key constraint' in s:
return 'Foreign key constraint violation'
if 'null value in column' in s:
m = re.search('null value in column "([^"]+)"', orig_str)
col = f' "{m.group(1)}"' if m else ''
return f'Required field{col} is missing'
if 'check constraint' in s:
return 'Check constraint violation'
if 'out of range' in s:
return 'Value out of allowed range'
if 'numeric field overflow' in s:
return 'Numeric field overflow'
first_line = orig_str.split('\n')[0].split('. ')[0].strip()
first_line = re.sub("^<class '[^']+'>:\\s*", '', first_line)
return first_line if first_line else 'Database error'
def _build_error_response(request: Request, exc: Exception, error_id: str) -> JSONResponse:
"""
Build JSON error response and store log metadata on request.state.
Status codes handled:
400 BadRequestError
401 UnauthorizedError (missing/expired token)
403 ForbiddenError (valid token, access denied)
404 NotFoundError
408 RequestTimeoutError
422 UnprocessableEntityError / validation errors / security payloads
429 TooManyRequestsError / RateLimitExceeded
500 InternalServerError / SQLAlchemy errors / unexpected
"""
# 1. Custom OptimumOH exceptions
for exc_class, status_code in _EXCEPTION_STATUS_MAP.items():
if isinstance(exc, exc_class):
error_type = _ERROR_TYPE_MAP[exc_class]
client_msg = _FIXED_MESSAGES.get(status_code, exc.message)
_store_log_state(request, status_code, error_id, error_type, exc.message)
return _json_response(status_code, f"{client_msg}. Error ID: {error_id}")
# 2. SlowAPI RateLimitExceeded
return _json_response(status_code, f'{client_msg}. Error ID: {error_id}')
if isinstance(exc, RateLimitExceeded):
try:
from src.auth.service import notify_admin_on_rate_limit_sync
request_info = get_request_context(request)
notify_admin_on_rate_limit_sync(
endpoint_name=request_info["endpoint"],
ip_address=request_info["remote_addr"],
method=request_info["method"],
request=request,
)
notify_admin_on_rate_limit_sync(endpoint_name=request_info['endpoint'], ip_address=request_info['remote_addr'], method=request_info['method'], request=request)
except Exception:
pass
_store_log_state(request, 429, error_id, "Too Many Requests", "Please try again later")
return _json_response(429, f"Please try again later. Error ID: {error_id}")
# 3. Pydantic / FastAPI RequestValidationError
_store_log_state(request, 429, error_id, 'Too Many Requests', 'Please try again later')
return _json_response(429, f'Please try again later. Error ID: {error_id}')
if isinstance(exc, RequestValidationError):
errors = exc.errors()
detail_parts = []
for err in errors:
loc = " -> ".join(str(part) for part in err.get("loc", []))
msg = err.get("msg", "")
detail_parts.append(f"{loc}: {msg}" if loc else msg)
detail_str = "; ".join(detail_parts) if detail_parts else "Input validation failed"
_store_log_state(request, 422, error_id, "Unprocessable Entity", detail_str)
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
# 4. Starlette/FastAPI HTTPException
loc = ' -> '.join((str(part) for part in err.get('loc', [])))
msg = err.get('msg', '')
detail_parts.append(f'{loc}: {msg}' if loc else msg)
detail_str = '; '.join(detail_parts) if detail_parts else 'Input validation failed'
_store_log_state(request, 422, error_id, 'Unprocessable Entity', detail_str)
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
if isinstance(exc, StarletteHTTPException):
status_code = exc.status_code
error_type = _HTTP_STATUS_NAMES.get(status_code, f"HTTP {status_code}")
error_type = _HTTP_STATUS_NAMES.get(status_code, f'HTTP {status_code}')
if status_code == 401:
log_msg = str(exc.detail) if exc.detail else "Invalid authorization token"
_store_log_state(request, 401, error_id, "Unauthorized", log_msg)
return _json_response(401, f"{_FIXED_MESSAGES[401]}. Error ID: {error_id}")
log_msg = str(exc.detail) if exc.detail else 'Invalid authorization token'
_store_log_state(request, 401, error_id, 'Unauthorized', log_msg)
return _json_response(401, f'{_FIXED_MESSAGES[401]}. Error ID: {error_id}')
if status_code == 422:
log_msg = str(exc.detail) if exc.detail else _FIXED_MESSAGES[422]
_store_log_state(request, 422, error_id, "Unprocessable Entity", log_msg)
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
_store_log_state(request, 422, error_id, 'Unprocessable Entity', log_msg)
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
if isinstance(exc.detail, dict):
log_msg = exc.detail.get("error", error_type)
log_msg = exc.detail.get('error', error_type)
else:
log_msg = str(exc.detail) if exc.detail else error_type
client_msg = _FIXED_MESSAGES.get(status_code, log_msg)
_store_log_state(request, status_code, error_id, error_type, log_msg)
return _json_response(status_code, f"{client_msg}. Error ID: {error_id}")
# 5. SQLAlchemy errors
return _json_response(status_code, f'{client_msg}. Error ID: {error_id}')
if isinstance(exc, SQLAlchemyError):
orig = getattr(exc, "orig", None)
orig = getattr(exc, 'orig', None)
orig_str = str(orig) if orig else str(exc)
short = _short_db_error(orig_str)
# NoResultFound: .one() / .scalar_one() found no row → 404
if isinstance(exc, NoResultFound):
_store_log_state(request, 404, error_id, "Not Found", "Resource not found")
return _json_response(404, f"Resource not found. Error ID: {error_id}")
_store_log_state(request, 404, error_id, 'Not Found', 'Resource not found')
return _json_response(404, f'Resource not found. Error ID: {error_id}')
if isinstance(exc, IntegrityError):
orig_lower = orig_str.lower()
if "unique constraint" in orig_lower:
_store_log_state(request, 409, error_id, "Conflict", f"Unique constraint violation: {short}")
return _json_response(409, f"This record already exists. Error ID: {error_id}")
elif "foreign key constraint" in orig_lower:
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Foreign key constraint violation: {short}")
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
else:
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Database integrity error: {short}")
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
if 'unique constraint' in orig_lower:
_store_log_state(request, 409, error_id, 'Conflict', f'Unique constraint violation: {short}')
return _json_response(409, f'This record already exists. Error ID: {error_id}')
if 'foreign key constraint' in orig_lower:
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Foreign key constraint violation: {short}')
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Database integrity error: {short}')
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
if isinstance(exc, DataError):
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Invalid data format: {short}")
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Invalid data format: {short}')
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
if isinstance(exc, DBAPIError):
orig_lower = orig_str.lower()
if "invalid input syntax" in orig_lower or "invalid uuid" in orig_lower or "invalid input for query argument" in orig_lower:
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Invalid input format: {short}")
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
elif "unique constraint" in orig_lower:
_store_log_state(request, 409, error_id, "Conflict", f"Unique constraint violation: {short}")
return _json_response(409, f"This record already exists. Error ID: {error_id}")
elif "foreign key constraint" in orig_lower:
_store_log_state(request, 422, error_id, "Unprocessable Entity", f"Foreign key constraint violation: {short}")
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
else:
_store_log_state(request, 500, error_id, "Internal Server Error", f"Database query error: {short}")
return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}")
_store_log_state(request, 500, error_id, "Internal Server Error", f"Database error: {short}")
return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}")
# 5b. httpx upstream errors (raise_for_status() from external service calls)
if 'invalid input syntax' in orig_lower or 'invalid uuid' in orig_lower or 'invalid input for query argument' in orig_lower:
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Invalid input format: {short}')
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
if 'unique constraint' in orig_lower:
_store_log_state(request, 409, error_id, 'Conflict', f'Unique constraint violation: {short}')
return _json_response(409, f'This record already exists. Error ID: {error_id}')
if 'foreign key constraint' in orig_lower:
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Foreign key constraint violation: {short}')
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}')
_store_log_state(request, 500, error_id, 'Internal Server Error', f'Database query error: {short}')
return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}')
_store_log_state(request, 500, error_id, 'Internal Server Error', f'Database error: {short}')
return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}')
try:
import httpx as _httpx
if isinstance(exc, _httpx.HTTPStatusError):
upstream_status = exc.response.status_code
error_type = _HTTP_STATUS_NAMES.get(upstream_status, f"HTTP {upstream_status}")
client_msg = _FIXED_MESSAGES.get(upstream_status, "Upstream service error")
log_msg = f"Upstream service returned {upstream_status}: {exc.request.url}"
error_type = _HTTP_STATUS_NAMES.get(upstream_status, f'HTTP {upstream_status}')
client_msg = _FIXED_MESSAGES.get(upstream_status, 'Upstream service error')
log_msg = f'Upstream service returned {upstream_status}: {exc.request.url}'
_store_log_state(request, upstream_status, error_id, error_type, log_msg)
return _json_response(upstream_status, f"{client_msg}. Error ID: {error_id}")
return _json_response(upstream_status, f'{client_msg}. Error ID: {error_id}')
if isinstance(exc, (_httpx.ConnectError, _httpx.TimeoutException, _httpx.RequestError)):
log_msg = f"Upstream service unreachable: {type(exc).__name__}: {exc}"
_store_log_state(request, 502, error_id, "Bad Gateway", log_msg)
return _json_response(502, f"Upstream service unavailable. Error ID: {error_id}")
log_msg = f'Upstream service unreachable: {type(exc).__name__}: {exc}'
_store_log_state(request, 502, error_id, 'Bad Gateway', log_msg)
return _json_response(502, f'Upstream service unavailable. Error ID: {error_id}')
except ImportError:
pass
# 6. Catch-all
detail = type(exc).__name__
_store_log_state(request, 500, error_id, "Internal Server Error", detail)
return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}")
# ── Single exception handler registered with FastAPI ─────────────────────────
_store_log_state(request, 500, error_id, 'Internal Server Error', detail)
return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}')
async def handle_exception(request: Request, exc: Exception) -> JSONResponse:
"""
Global exception handler.
Sets request.state.log_* so that db_session_middleware emits a single structured log.
"""
error_id = str(uuid.uuid4())
if not hasattr(request.state, "request_id"):
if not hasattr(request.state, 'request_id'):
request.state.request_id = error_id
return _build_error_response(request, exc, error_id)
# ── Legacy helper kept for backward compatibility ─────────────────────────────
def handle_sqlalchemy_error(error: SQLAlchemyError):
"""Return (user_message, status_code) for a SQLAlchemy error."""
original_error = error.orig if hasattr(error, "orig") else None
original_error = error.orig if hasattr(error, 'orig') else None
if isinstance(error, IntegrityError):
if "unique constraint" in str(error).lower():
return "This record already exists.", 409
elif "foreign key constraint" in str(error).lower():
return "Related record not found.", 400
else:
return "Data integrity error.", 400
elif isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError):
return "Invalid data provided.", 400
elif isinstance(error, DBAPIError):
if "unique constraint" in str(error).lower():
return "This record already exists.", 409
elif "foreign key constraint" in str(error).lower():
return "Related record not found.", 400
elif "null value in column" in str(error).lower():
return "Required data missing.", 400
elif "invalid input for query argument" in str(error).lower():
return "Invalid data provided.", 400
else:
return "Database error.", 500
else:
log.error(f"Unexpected database error: {str(error)}")
return "An unexpected database error occurred.", 500
if 'unique constraint' in str(error).lower():
return ('This record already exists.', 409)
if 'foreign key constraint' in str(error).lower():
return ('Related record not found.', 400)
return ('Data integrity error.', 400)
if isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError):
return ('Invalid data provided.', 400)
if isinstance(error, DBAPIError):
if 'unique constraint' in str(error).lower():
return ('This record already exists.', 409)
if 'foreign key constraint' in str(error).lower():
return ('Related record not found.', 400)
if 'null value in column' in str(error).lower():
return ('Required data missing.', 400)
if 'invalid input for query argument' in str(error).lower():
return ('Invalid data provided.', 400)
return ('Database error.', 500)
log.error(f'Unexpected database error: {str(error)}')
return ('An unexpected database error occurred.', 500)

@ -2,323 +2,152 @@ import logging
import asyncio
import time
from src.context import set_request_id, reset_request_id, get_request_id
from contextvars import ContextVar
from os import path
from typing import Final, Optional
from uuid import uuid1
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi import Depends, FastAPI, HTTPException
from src.auth.service import JWTBearer
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import async_scoped_session
from sqlalchemy.orm import scoped_session
from starlette.middleware.base import (BaseHTTPMiddleware,
RequestResponseEndpoint)
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.requests import Request
from starlette.responses import FileResponse, Response, StreamingResponse
from starlette.routing import compile_path
from starlette.staticfiles import StaticFiles
from starlette.responses import Response
import uuid as _uuid_module
from src.api import api_router
from src.database.core import async_session, engine, async_collector_session
from src.database.core import async_session, async_collector_session
from src.enums import ResponseStatus
from src.exceptions import handle_exception
from src.app_logging import configure_logging
from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware
# from src.rate_limiter import limiter
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from src.csrf_protect import csrf_protect
log = logging.getLogger(__name__)
# we configure the logging level and format
configure_logging()
REQUEST_TIMEOUT_SECONDS = 20
app = FastAPI(openapi_url='', title='Optimum OH API', description="Welcome to Optimum OH's API documentation!", version='0.1.0')
# we create the ASGI for the app
app = FastAPI(
openapi_url="",
title="Optimum OH API",
description="Welcome to Optimum OH's API documentation!",
version="0.1.0",
)
# Sync handler for RateLimitExceeded — SlowAPIMiddleware falls back to default
# when the registered handler is async, so this must be a plain sync function.
def _sync_rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
error_id = str(_uuid_module.uuid4())
request.state.log_status_code = 429
request.state.log_error_id = error_id
request.state.log_error_type = "Too Many Requests"
request.state.log_error_message = "Please try again later"
return JSONResponse(
status_code=429,
content={"data": None, "message": f"Please try again later. Error ID: {error_id}", "status": ResponseStatus.ERROR},
)
# we define the exception handlers
request.state.log_error_type = 'Too Many Requests'
request.state.log_error_message = 'Please try again later'
return JSONResponse(status_code=429, content={'data': None, 'message': f'Please try again later. Error ID: {error_id}', 'status': ResponseStatus.ERROR})
app.add_exception_handler(Exception, handle_exception)
app.add_exception_handler(HTTPException, handle_exception)
app.add_exception_handler(StarletteHTTPException, handle_exception)
app.add_exception_handler(RequestValidationError, handle_exception)
app.add_exception_handler(RateLimitExceeded, _sync_rate_limit_handler)
# app.state.limiter = limiter
app.add_middleware(GZipMiddleware, minimum_size=1000)
# app.add_middleware(SlowAPIMiddleware)
app.add_middleware(RequestTimeoutMiddleware, timeout=REQUEST_TIMEOUT_SECONDS)
# credentials: "include",
@app.route('/slow', methods=["GET"])
@app.route('/slow', methods=['GET'])
async def slow_endpoint(request: Request):
await asyncio.sleep(REQUEST_TIMEOUT_SECONDS + 10)
return JSONResponse(content={"message": "This should timeout"}, status_code=200)
return JSONResponse(content={'message': 'This should timeout'}, status_code=200)
@app.post('/testcsrf', dependencies=[Depends(JWTBearer()), Depends(csrf_protect)])
async def test_csrf(request: Request):
return JSONResponse(
content={"data": None, "message": "CSRF token is valid", "status": ResponseStatus.SUCCESS},
status_code=200,
)
return JSONResponse(content={'data': None, 'message': 'CSRF token is valid', 'status': ResponseStatus.SUCCESS}, status_code=200)
def security_headers_middleware(app: FastAPI):
is_production = False
csp_policy = {'default-src': "'self'", 'script-src': "'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", 'style-src': "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net", 'img-src': "'self' data: https: blob:", 'font-src': "'self' https://fonts.gstatic.com data:", 'connect-src': "'self' https://api.your-domain.com wss://ws.your-domain.com", 'frame-src': "'none'", 'object-src': "'none'", 'base-uri': "'self'", 'form-action': "'self'"}
feature_policy = {'geolocation': "'none'", 'midi': "'none'", 'notifications': "'none'", 'push': "'none'", 'sync-xhr': "'none'", 'microphone': "'none'", 'camera': "'none'", 'magnetometer': "'none'", 'gyroscope': "'none'", 'speaker': "'none'", 'vibrate': "'none'", 'fullscreen': "'self'", 'payment': "'none'"}
csp_header_value = '; '.join((f'{k} {v}' for k, v in csp_policy.items()))
feature_header_value = '; '.join((f'{k}={v}' for k, v in feature_policy.items()))
# CSP rules
csp_policy = {
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net",
"img-src": "'self' data: https: blob:",
"font-src": "'self' https://fonts.gstatic.com data:",
"connect-src": "'self' https://api.your-domain.com wss://ws.your-domain.com",
"frame-src": "'none'",
"object-src": "'none'",
"base-uri": "'self'",
"form-action": "'self'",
}
# Feature / Permissions Policy
feature_policy = {
"geolocation": "'none'",
"midi": "'none'",
"notifications": "'none'",
"push": "'none'",
"sync-xhr": "'none'",
"microphone": "'none'",
"camera": "'none'",
"magnetometer": "'none'",
"gyroscope": "'none'",
"speaker": "'none'",
"vibrate": "'none'",
"fullscreen": "'self'",
"payment": "'none'",
}
csp_header_value = "; ".join(f"{k} {v}" for k, v in csp_policy.items())
feature_header_value = "; ".join(f"{k}={v}" for k, v in feature_policy.items())
# Middleware definition
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
if is_production:
response.headers["Strict-Transport-Security"] = "max-age=15724800; includeSubDomains; preload"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = csp_header_value
response.headers["Permissions-Policy"] = feature_header_value
response.headers['Strict-Transport-Security'] = 'max-age=15724800; includeSubDomains; preload'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Content-Security-Policy'] = csp_header_value
response.headers['Permissions-Policy'] = feature_header_value
else:
# Relaxed settings for development
response.headers["Content-Security-Policy"] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' *"
# You can skip some headers here for local testing
response.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' *"
return response
app.add_middleware(SecurityHeadersMiddleware)
security_headers_middleware(app)
app.add_middleware(RequestValidationMiddleware)
# app.add_middleware(
# CSRFProtectMiddleware,
# excluded_paths=["/healthcheck"]
# )
@app.middleware("http")
@app.middleware('http')
async def db_session_middleware(request: Request, call_next):
request_id = str(uuid1())
# Per-request ID scopes the SQLAlchemy session.
# see: https://github.com/tiangolo/fastapi/issues/726
ctx_token = set_request_id(request_id)
request.state.request_id = request_id
try:
session = async_scoped_session(async_session, scopefunc=get_request_id)
request.state.db = session()
collector_session = async_scoped_session(async_collector_session, scopefunc=get_request_id)
request.state.collector_db = collector_session()
start_time = time.time()
try:
response = await call_next(request)
except Exception as exc:
# Non-HTTP exceptions (SQLAlchemy, httpx, ValueError, etc.) are NOT caught by
# ExceptionMiddleware because app.add_exception_handler(Exception, ...) registers
# with ServerErrorMiddleware, not ExceptionMiddleware. If we let such exceptions
# escape here, db_session_middleware exits via finally WITHOUT logging, then
# ServerErrorMiddleware calls handle_exception (giving the 500 response) and
# re-raises, causing uvicorn to emit an all-null JSON log entry.
# Catching here ensures: proper error response, structured log, no re-raise.
response = await handle_exception(request, exc)
duration_ms = round((time.time() - start_time) * 1000, 2)
# ── Determine status_code ─────────────────────────────────────────────
# RequestTimeoutMiddleware flags the scope when a 408 bypasses exception handlers
timed_out = request.scope.get("x_timed_out", False)
timed_out = request.scope.get('x_timed_out', False)
if timed_out:
status_code = 408
error_id = request.scope.get("x_timeout_error_id", None)
error_type = "Request Timeout"
error_message = "Request timeout over 20 seconds"
error_id = request.scope.get('x_timeout_error_id', None)
error_type = 'Request Timeout'
error_message = 'Request timeout over 20 seconds'
else:
status_code = getattr(request.state, "log_status_code", None) or response.status_code
error_id = getattr(request.state, "log_error_id", None)
error_type = getattr(request.state, "log_error_type", None)
error_message = getattr(request.state, "log_error_message", None)
# ── Fallback error labels for responses that skip exception handler ───
_FALLBACK_ERROR_TYPES = {
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
404: "Not Found", 408: "Request Timeout", 409: "Conflict",
422: "Unprocessable Entity", 429: "Too Many Requests",
500: "Internal Server Error",
}
_FALLBACK_ERROR_MESSAGES = {
400: "Bad request", 401: "Invalid authorization token",
403: "Forbidden Access", 404: "Resource not found",
408: "Request timeout", 409: "This record already exists",
422: "Input validation failed", 429: "Please try again later",
500: "An unexpected error occurred",
}
if status_code >= 400 and not error_type:
error_type = _FALLBACK_ERROR_TYPES.get(status_code, f"HTTP {status_code}")
if status_code >= 400 and not error_message:
error_message = _FALLBACK_ERROR_MESSAGES.get(status_code, "Request failed")
# ── User info ─────────────────────────────────────────────────────────
status_code = getattr(request.state, 'log_status_code', None) or response.status_code
error_id = getattr(request.state, 'log_error_id', None)
error_type = getattr(request.state, 'log_error_type', None)
error_message = getattr(request.state, 'log_error_message', None)
_FALLBACK_ERROR_TYPES = {400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 408: 'Request Timeout', 409: 'Conflict', 422: 'Unprocessable Entity', 429: 'Too Many Requests', 500: 'Internal Server Error'}
_FALLBACK_ERROR_MESSAGES = {400: 'Bad request', 401: 'Invalid authorization token', 403: 'Forbidden Access', 404: 'Resource not found', 408: 'Request timeout', 409: 'This record already exists', 422: 'Input validation failed', 429: 'Please try again later', 500: 'An unexpected error occurred'}
if status_code >= 400 and (not error_type):
error_type = _FALLBACK_ERROR_TYPES.get(status_code, f'HTTP {status_code}')
if status_code >= 400 and (not error_message):
error_message = _FALLBACK_ERROR_MESSAGES.get(status_code, 'Request failed')
from src.context import get_user_id, set_user_id
user_id = get_user_id()
user_obj = getattr(request.state, "user", None)
if user_obj and not user_id:
user_obj = getattr(request.state, 'user', None)
if user_obj and (not user_id):
if isinstance(user_obj, dict):
u_id = user_obj.get("user_id")
u_id = user_obj.get('user_id')
else:
u_id = getattr(user_obj, "user_id", None)
u_id = getattr(user_obj, 'user_id', None)
if u_id:
user_id = str(u_id)
set_user_id(user_id)
# ── Client IP ─────────────────────────────────────────────────────────
headers = request.headers
if "X-Real-IP" in headers:
user_ip = headers["X-Real-IP"]
elif "X-Forwarded-For" in headers:
user_ip = headers["X-Forwarded-For"].split(",")[0].strip()
if 'X-Real-IP' in headers:
user_ip = headers['X-Real-IP']
elif 'X-Forwarded-For' in headers:
user_ip = headers['X-Forwarded-For'].split(',')[0].strip()
else:
user_ip = request.client.host if request.client else None
# ── Full path with query string ───────────────────────────────────────
path = request.url.path
if request.url.query:
path = f"{path}?{request.url.query}"
# ── Emit single structured log ────────────────────────────────────────
result = "Request completed" if status_code < 400 else "Request failed"
path = f'{path}?{request.url.query}'
result = 'Request completed' if status_code < 400 else 'Request failed'
if status_code >= 500:
log_level = logging.ERROR
elif status_code >= 400:
log_level = logging.WARNING
else:
log_level = logging.INFO
log.log(
log_level,
f"{request.method} {path} {status_code}",
extra={
"method": request.method,
"path": path,
"status_code": status_code,
"duration_ms": duration_ms,
"request_id": request_id,
"user_id": user_id,
"user_ip": user_ip,
"error_id": error_id,
"result": result,
"error_type": error_type if status_code >= 400 else None,
"error_message": error_message if status_code >= 400 else None,
},
)
log.log(log_level, f'{request.method} {path} {status_code}', extra={'method': request.method, 'path': path, 'status_code': status_code, 'duration_ms': duration_ms, 'request_id': request_id, 'user_id': user_id, 'user_ip': user_ip, 'error_id': error_id, 'result': result, 'error_type': error_type if status_code >= 400 else None, 'error_message': error_message if status_code >= 400 else None})
finally:
await request.state.db.close()
await request.state.collector_db.close()
reset_request_id(ctx_token)
return response
@app.middleware("http")
@app.middleware('http')
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["Strict-Transport-Security"] = (
"max-age=31536000 ; includeSubDomains"
)
response.headers['Strict-Transport-Security'] = 'max-age=31536000 ; includeSubDomains'
return response
# class MetricsMiddleware(BaseHTTPMiddleware):
# async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
# method = request.method
# endpoint = request.url.path
# tags = {"method": method, "endpoint": endpoint}
# try:
# start = time.perf_counter()
# response = await call_next(request)
# elapsed_time = time.perf_counter() - start
# tags.update({"status_code": response.status_code})
# metric_provider.counter("server.call.counter", tags=tags)
# metric_provider.timer("server.call.elapsed", value=elapsed_time, tags=tags)
# log.debug(f"server.call.elapsed.{endpoint}: {elapsed_time}")
# except Exception as e:
# metric_provider.counter("server.call.exception.counter", tags=tags)
# raise e from None
# return response
# app.add_middleware(ExceptionMiddleware)
app.include_router(api_router)

@ -1,10 +1,8 @@
from sqlalchemy import Column, BigInteger, Integer, Float, String, Text, DateTime
from src.database.core import CollectorBase
class WorkOrderData(CollectorBase):
__tablename__ = "wo_staging_3"
__tablename__ = 'wo_staging_3'
id = Column(BigInteger, primary_key=True, autoincrement=True)
assetnum = Column(Text, nullable=True)
description1 = Column(Text, nullable=True)

@ -1,332 +1,37 @@
from datetime import datetime
from typing import Optional, Union
from sqlalchemy import select, func, cast, Numeric, text
from sqlalchemy.orm import Session
from sqlalchemy import and_
from sqlalchemy.sql import not_
from src.maximo.model import WorkOrderData # Assuming this is where your model is
from sqlalchemy import select, text
from src.database.core import CollectorDbSession, DbSession
from src.overhaul_scope.model import OverhaulScope
async def get_cm_cost_summary(collector_db: CollectorDbSession, last_oh_date: datetime, upcoming_oh_date: datetime):
query = text("""WITH part_costs AS (
SELECT
mu.wonum,
SUM(mu.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost
FROM maximo_workorder_materials mu
LEFT JOIN maximo_inventory inv
ON mu.itemnum = inv.itemnum
LEFT JOIN (
SELECT item_num, AVG(unit_cost) AS unit_cost
FROM maximo_sparepart_pr_po_line
GROUP BY item_num
) po
ON mu.itemnum = po.item_num
GROUP BY mu.wonum
),
wo_costs AS (
SELECT
w.wonum,
w.asset_location,
(COALESCE(w.mat_cost_max, 0) + COALESCE(pc.parts_total_cost, 0)) AS total_wo_cost
FROM wo_staging_maximo_2 w
LEFT JOIN part_costs pc
ON w.wonum = pc.wonum
WHERE
w.worktype IN ('CM', 'EM', 'PROACTIVE')
AND w.asset_system IN (
'HPB','AH','APC','SCR','CL','DM','CRH','ASH','BAD','DS','WTP',
'MT','SUP','DCS','FF','EG','AI','SPS','EVM','SCW','KLH','CH',
'TUR','LOT','HRH','ESP','CAE','GMC','BFT','LSH','CHB','BSS',
'LOS','LPB','SAC','CP','EHS','RO','GG','MS','CW','SO','ATT',
'AFG','EHB','RP','FO','PC','APE','AF','DMW','BRS','GEN','ABS',
'CHA','TR','H2','BDW','LOM','ACR','AL','FW','COND','CCCW','IA',
'GSS','BOL','SSB','CO','OA','CTH-UPD','AS','DP'
)
AND w.reportdate IS NOT NULL
AND w.actstart IS NOT NULL
AND w.actfinish IS NOT NULL
AND w.asset_unit IN ('3','00')
AND w.reportdate >= '2015-01-01'
AND w.wonum NOT LIKE 'T%'
),
-- find max cost per location
location_max AS (
SELECT asset_location, MAX(total_wo_cost) AS max_cost
FROM wo_costs
WHERE total_wo_cost > 0
GROUP BY asset_location
),
-- filter WO costs to only reasonable range (e.g. >0 and >=10% of max)
filtered_wo AS (
SELECT w.*
FROM wo_costs w
JOIN location_max lm ON w.asset_location = lm.asset_location
WHERE w.total_wo_cost > 0
)
SELECT
asset_location,
SUM(total_wo_cost)::numeric / COUNT(wonum) AS avg_cost
FROM filtered_wo
GROUP BY asset_location
ORDER BY avg_cost DESC;
""")
query = text("WITH part_costs AS (\n SELECT \n mu.wonum,\n SUM(mu.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost\n FROM maximo_workorder_materials mu\n LEFT JOIN maximo_inventory inv\n ON mu.itemnum = inv.itemnum\n LEFT JOIN (\n SELECT item_num, AVG(unit_cost) AS unit_cost\n FROM maximo_sparepart_pr_po_line\n GROUP BY item_num\n ) po\n ON mu.itemnum = po.item_num\n GROUP BY mu.wonum\n),\nwo_costs AS (\n SELECT \n w.wonum,\n w.asset_location,\n (COALESCE(w.mat_cost_max, 0) + COALESCE(pc.parts_total_cost, 0)) AS total_wo_cost\n FROM wo_staging_maximo_2 w\n LEFT JOIN part_costs pc\n ON w.wonum = pc.wonum\n WHERE \n w.worktype IN ('CM', 'EM', 'PROACTIVE')\n AND w.asset_system IN (\n 'HPB','AH','APC','SCR','CL','DM','CRH','ASH','BAD','DS','WTP',\n 'MT','SUP','DCS','FF','EG','AI','SPS','EVM','SCW','KLH','CH',\n 'TUR','LOT','HRH','ESP','CAE','GMC','BFT','LSH','CHB','BSS',\n 'LOS','LPB','SAC','CP','EHS','RO','GG','MS','CW','SO','ATT',\n 'AFG','EHB','RP','FO','PC','APE','AF','DMW','BRS','GEN','ABS',\n 'CHA','TR','H2','BDW','LOM','ACR','AL','FW','COND','CCCW','IA',\n 'GSS','BOL','SSB','CO','OA','CTH-UPD','AS','DP'\n )\n AND w.reportdate IS NOT NULL\n AND w.actstart IS NOT NULL\n AND w.actfinish IS NOT NULL\n AND w.asset_unit IN ('3','00')\n AND w.reportdate >= '2015-01-01'\n AND w.wonum NOT LIKE 'T%'\n),\n-- find max cost per location\nlocation_max AS (\n SELECT asset_location, MAX(total_wo_cost) AS max_cost\n FROM wo_costs\n WHERE total_wo_cost > 0\n GROUP BY asset_location\n),\n-- filter WO costs to only reasonable range (e.g. >0 and >=10% of max)\nfiltered_wo AS (\n SELECT w.*\n FROM wo_costs w\n JOIN location_max lm ON w.asset_location = lm.asset_location\n WHERE w.total_wo_cost > 0\n)\nSELECT \n asset_location,\n SUM(total_wo_cost)::numeric / COUNT(wonum) AS avg_cost\nFROM filtered_wo\nGROUP BY asset_location\nORDER BY avg_cost DESC;\n ")
results = await collector_db.execute(query)
data = []
for row in results:
data.append({
"location_tag": row.asset_location,
"avg_cost": row.avg_cost
})
return {
item["location_tag"]: item["avg_cost"] for item in data
}
# async def get_oh_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime):
# query = text("""
# WITH target_wo AS (
# -- Get work orders under a specific parent(s)
# SELECT
# wonum,
# xx_parent,
# assetnum,
# location_tag AS asset_location,
# actmatcost,
# actservcost,
# reportdate
# FROM public.wo_maxim
# WHERE xx_parent = ANY(:parent_nums)
# ),
# part_costs AS (
# -- Calculate parts cost per WO if actmatcost = 0
# SELECT
# wm.wonum,
# SUM(
# wm.itemqty *
# COALESCE(wm.inv_avgcost, po.unit_cost, 0)
# ) AS parts_total_cost
# FROM public.wo_maxim_material wm
# LEFT JOIN (
# SELECT item_num, AVG(unit_cost) AS unit_cost
# FROM public.maximo_sparepart_pr_po_line
# GROUP BY item_num
# ) po ON wm.itemnum = po.item_num
# WHERE wm.itemnum IS NOT NULL
# GROUP BY wm.wonum
# ),
# wo_costs AS (
# SELECT
# w.wonum,
# w.asset_location,
# CASE
# WHEN COALESCE(w.actmatcost, 0) > 0 THEN COALESCE(w.actmatcost, 0)
# ELSE COALESCE(pc.parts_total_cost, 0)
# END AS material_cost,
# COALESCE(w.actservcost, 0) AS service_cost
# FROM target_wo w
# LEFT JOIN part_costs pc ON w.wonum = pc.wonum
# )
# SELECT
# asset_location,
# ROUND(SUM(material_cost + service_cost)::numeric / COUNT(wonum), 2) AS avg_cost,
# COUNT(wonum) AS total_wo_count
# FROM wo_costs
# GROUP BY asset_location
# ORDER BY total_wo_count DESC;
# """)
# parent_nums = []
# result = await collector_db.execute(query, {"parent_nums": parent_nums})
# data = []
# for row in result:
# data.append({
# "location_tag": row.asset_location,
# "avg_cost": float(row.avg_cost or 0.0),
# "total_wo_count": row.total_wo_count,
# })
# return {item["location_tag"]: item["avg_cost"] for item in data}
data.append({'location_tag': row.asset_location, 'avg_cost': row.avg_cost})
return {item['location_tag']: item['avg_cost'] for item in data}
async def get_oh_cost_summary(collector_db: CollectorDbSession, last_oh_date: datetime, upcoming_oh_date: datetime):
# query = text("""
# WITH part_costs AS (
# SELECT
# wm.wonum,
# SUM(wm.itemqty * COALESCE(wm.inv_avgcost, po.unit_cost, 0)) AS parts_total_cost
# FROM public.wo_maxim_material wm
# LEFT JOIN (
# SELECT item_num, AVG(unit_cost) AS unit_cost
# FROM public.maximo_sparepart_pr_po_line
# GROUP BY item_num
# ) po ON wm.itemnum = po.item_num
# WHERE wm.itemnum IS NOT NULL
# GROUP BY wm.wonum
# ),
# wo_costs AS (
# SELECT
# w.wonum,
# w.asset_location,
# -- Use mat_cost_max if parts_total_cost = 0
# CASE
# WHEN COALESCE(pc.parts_total_cost, 0) = 0 THEN COALESCE(w.mat_cost_max , 0)
# ELSE COALESCE(pc.parts_total_cost, 0)
# END AS total_wo_cost
# FROM wo_staging_maximo_2 w
# LEFT JOIN part_costs pc
# ON w.wonum = pc.wonum
# WHERE
# w.worktype = 'OH'
# AND w.reportdate IS NOT NULL
# AND w.actstart IS NOT NULL
# AND w.actfinish IS NOT NULL
# AND w.asset_unit IN ('3', '00')
# AND w.wonum NOT LIKE 'T%'
# )
# SELECT
# asset_location,
# AVG(total_wo_cost) AS avg_cost
# FROM wo_costs
# GROUP BY asset_location
# ORDER BY COUNT(wonum) DESC;
# """)
query = text("""
WITH part_costs AS (
SELECT
wm.wonum,
SUM(wm.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost
FROM public.maximo_workorder_materials wm
JOIN public.maximo_inventory AS inv on inv.itemnum = wm.itemnum
LEFT JOIN (
SELECT item_num, AVG(unit_cost) AS unit_cost
FROM public.maximo_sparepart_pr_po_line
GROUP BY item_num
) po ON wm.itemnum = po.item_num
WHERE wm.itemnum IS NOT NULL
GROUP BY wm.wonum
),
wo_costs AS (
SELECT
w.wonum,
w.asset_location,
-- Use mat_cost_max if parts_total_cost = 0
CASE
WHEN COALESCE(pc.parts_total_cost, 0) = 0 THEN COALESCE(w.mat_cost_max , 0)
ELSE COALESCE(pc.parts_total_cost, 0)
END AS total_wo_cost
FROM wo_staging_maximo_2 w
LEFT JOIN part_costs pc
ON w.wonum = pc.wonum
WHERE
w.worktype = 'OH'
AND w.reportdate IS NOT NULL
AND w.actstart IS NOT NULL
AND w.actfinish IS NOT NULL
AND w.asset_unit IN ('3', '00')
AND w.wonum NOT LIKE 'T%'
)
SELECT
asset_location,
AVG(total_wo_cost) AS avg_cost
FROM wo_costs
GROUP BY asset_location
ORDER BY COUNT(wonum) DESC;
""")
query = text("\n WITH part_costs AS (\n SELECT \n wm.wonum,\n SUM(wm.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost\n FROM public.maximo_workorder_materials wm\n JOIN public.maximo_inventory AS inv on inv.itemnum = wm.itemnum\n LEFT JOIN (\n SELECT item_num, AVG(unit_cost) AS unit_cost\n FROM public.maximo_sparepart_pr_po_line\n GROUP BY item_num\n ) po ON wm.itemnum = po.item_num\n WHERE wm.itemnum IS NOT NULL\n GROUP BY wm.wonum\n ),\n wo_costs AS (\n SELECT \n w.wonum,\n w.asset_location,\n -- Use mat_cost_max if parts_total_cost = 0\n CASE \n WHEN COALESCE(pc.parts_total_cost, 0) = 0 THEN COALESCE(w.mat_cost_max , 0)\n ELSE COALESCE(pc.parts_total_cost, 0)\n END AS total_wo_cost\n FROM wo_staging_maximo_2 w\n LEFT JOIN part_costs pc\n ON w.wonum = pc.wonum\n WHERE \n w.worktype = 'OH'\n AND w.reportdate IS NOT NULL\n AND w.actstart IS NOT NULL\n AND w.actfinish IS NOT NULL\n AND w.asset_unit IN ('3', '00')\n AND w.wonum NOT LIKE 'T%'\n )\n SELECT \n asset_location,\n AVG(total_wo_cost) AS avg_cost\n FROM wo_costs\n GROUP BY asset_location\n ORDER BY COUNT(wonum) DESC;\n ")
result = await collector_db.execute(query)
data = []
for row in result:
data.append({
"location_tag": row.asset_location,
"avg_cost": row.avg_cost
})
return {
item["location_tag"]: item["avg_cost"] for item in data
}
data.append({'location_tag': row.asset_location, 'avg_cost': row.avg_cost})
return {item['location_tag']: item['avg_cost'] for item in data}
from uuid import UUID
async def get_history_oh_wo(*, db_session: DbSession, collector_db_session: CollectorDbSession, oh_session_id: UUID, parent_wo_num: Optional[Union[str, list]]=None):
## Get Parent wo num from oh session table
if not parent_wo_num:
query = select(OverhaulScope.wo_parent).where(OverhaulScope.id == oh_session_id)
result = await db_session.execute(query)
parent_wo_num = result.scalar()
if not parent_wo_num:
return []
# Ensure parent_wo_num is a list and removed duplicates if any
if isinstance(parent_wo_num, str):
parent_wo_num = [parent_wo_num]
else:
parent_wo_num = list(set(parent_wo_num))
sql_query = text("""
WITH target_wos AS (
SELECT
w.wonum,
w.assetnum,
COALESCE(w.actmatcost, 0) as actmatcost,
COALESCE(w.actservcost, 0) as actservcost
FROM public.wo_maximo w
WHERE w.xx_parent = ANY(:parent_wo_num)
),
wo_tasks AS (
SELECT
t.xx_parent AS parent_wonum,
JSON_AGG(t.description) AS task_list
FROM public.wo_maximo t
JOIN target_wos tw ON t.xx_parent = tw.wonum
GROUP BY t.xx_parent
)
SELECT
w.assetnum,
e.name AS equipment_name,
e.location_tag,
JSON_OBJECT_AGG(w.wonum, COALESCE(wt.task_list, '[]'::json)) AS wonum_list,
COUNT(w.wonum) AS total_wo_count,
COALESCE(SUM(w.actmatcost), 0) AS total_material_cost,
COALESCE(SUM(w.actservcost), 0) AS total_service_cost,
COALESCE(SUM(w.actmatcost + w.actservcost), 0) AS total_actual_cost
FROM target_wos w
INNER JOIN public.ms_equipment_master e
ON w.assetnum = e.assetnum
LEFT JOIN wo_tasks wt
ON w.wonum = wt.parent_wonum
GROUP BY
w.assetnum,
e.name,
e.location_tag
ORDER BY total_actual_cost DESC;
""")
results = await collector_db_session.execute(sql_query, {"parent_wo_num": parent_wo_num})
return [
{
"assetnum": row.assetnum,
"equipment_name": row.equipment_name,
"location_tag": row.location_tag,
"wonum_list": row.wonum_list,
"total_wo_count": row.total_wo_count,
"total_material_cost": float(row.total_material_cost),
"total_service_cost": float(row.total_service_cost),
"total_actual_cost": float(row.total_actual_cost)
}
for row in results
]
sql_query = text("\n WITH target_wos AS (\n SELECT \n w.wonum,\n w.assetnum,\n COALESCE(w.actmatcost, 0) as actmatcost,\n COALESCE(w.actservcost, 0) as actservcost\n FROM public.wo_maximo w\n WHERE w.xx_parent = ANY(:parent_wo_num)\n ),\n wo_tasks AS (\n SELECT \n t.xx_parent AS parent_wonum,\n JSON_AGG(t.description) AS task_list\n FROM public.wo_maximo t\n JOIN target_wos tw ON t.xx_parent = tw.wonum\n GROUP BY t.xx_parent\n )\n SELECT \n w.assetnum,\n e.name AS equipment_name,\n e.location_tag,\n JSON_OBJECT_AGG(w.wonum, COALESCE(wt.task_list, '[]'::json)) AS wonum_list,\n COUNT(w.wonum) AS total_wo_count,\n COALESCE(SUM(w.actmatcost), 0) AS total_material_cost,\n COALESCE(SUM(w.actservcost), 0) AS total_service_cost,\n COALESCE(SUM(w.actmatcost + w.actservcost), 0) AS total_actual_cost\n FROM target_wos w\n INNER JOIN public.ms_equipment_master e \n ON w.assetnum = e.assetnum\n LEFT JOIN wo_tasks wt \n ON w.wonum = wt.parent_wonum\n GROUP BY \n w.assetnum, \n e.name, \n e.location_tag\n ORDER BY total_actual_cost DESC;\n ")
results = await collector_db_session.execute(sql_query, {'parent_wo_num': parent_wo_num})
return [{'assetnum': row.assetnum, 'equipment_name': row.equipment_name, 'location_tag': row.location_tag, 'wonum_list': row.wonum_list, 'total_wo_count': row.total_wo_count, 'total_material_cost': float(row.total_material_cost), 'total_service_cost': float(row.total_service_cost), 'total_actual_cost': float(row.total_actual_cost)} for row in results]

@ -1,46 +1 @@
# import logging
# from dispatch.plugins.base import plugins
# from .config import METRIC_PROVIDERS
# log = logging.getLogger(__file__)
# class Metrics(object):
# _providers = []
# def __init__(self):
# if not METRIC_PROVIDERS:
# log.info(
# "No metric providers defined via METRIC_PROVIDERS env var. Metrics will not be sent."
# )
# else:
# self._providers = METRIC_PROVIDERS
# def gauge(self, name, value, tags=None):
# for provider in self._providers:
# log.debug(
# f"Sending gauge metric {name} to provider {provider}. Value: {value} Tags: {tags}"
# )
# p = plugins.get(provider)
# p.gauge(name, value, tags=tags)
# def counter(self, name, value=None, tags=None):
# for provider in self._providers:
# log.debug(
# f"Sending counter metric {name} to provider {provider}. Value: {value} Tags: {tags}"
# )
# p = plugins.get(provider)
# p.counter(name, value=value, tags=tags)
# def timer(self, name, value, tags=None):
# for provider in self._providers:
# log.debug(
# f"Sending timer metric {name} to provider {provider}. Value: {value} Tags: {tags}"
# )
# p = plugins.get(provider)
# p.timer(name, value, tags=tags)
# provider = Metrics()

@ -4,261 +4,55 @@ import logging
from collections import Counter
from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
# =========================
# Configuration
# =========================
ALLOWED_MULTI_PARAMS = {
"sortBy[]",
"descending[]",
"exclude[]",
"assetnums",
"plant_ids",
"job_ids",
}
ALLOWED_DATA_PARAMS = {
"actual_shutdown", "all_params", "analysis_metadata", "asset_contributions",
"assetnum", "assetnums", "assigned_date", "availability", "availableScopes",
"avg_cost", "birbaum", "calculation_type", "capacity_weight", "code",
"contribution", "corrective_cost", "corrective_costs", "cost", "costPerFailure",
"cost_savings_vs_planned", "cost_threshold", "cost_trend", "created_at",
"crew_number", "criticalParts", "critical_procurement_items", "current_eaf",
"current_plant_eaf", "current_stock", "current_user", "cut_hours",
"daily_failures", "data", "datetime", "day", "days", "descending",
"description", "down_time", "duration", "duration_oh", "eaf_gap",
"eaf_improvement_text", "eaf_input", "efficiency", "end_date",
"equipment_name", "equipment_results", "equipment_with_sparepart_constraints",
"exclude", "excluded_equipment", "expected_delivery_date", "filter_spec",
"finish", "fleet_statistics", "id", "improvement_impact", "included_equipment",
"included_in_optimization", "intervalDays", "is_included", "itemnum",
"items", "itemsPerPage", "items_per_page", "job", "job_ids",
"last_overhaul_date", "lead_time", "location", "location_tag", "location_tags",
"maintenance_type", "master_equipment", "material_cost", "max_interval",
"max_interval_months", "message", "month", "months_from_planned", "name",
"next_planned_overhaul", "node", "num_failures", "num_of_failures",
"ohSessionId", "oh_scope", "oh_session_id", "oh_type", "oh_types",
"optimal_analysis", "optimal_breakdown", "optimal_month", "optimal_total_cost",
"optimization_success", "optimum_analysis", "optimum_day", "optimum_oh",
"optimum_oh_day", "optimum_oh_month", "order_date", "overhaulCost",
"overhaul_activity", "overhaul_cost", "overhaul_costs",
"overhaul_reference_type", "overhaul_scope", "overhaul_scope_id", "overview",
"page", "parent", "parent_id", "plan_duration", "planned_month",
"planned_outage", "plant_level_benefit", "po_pr_id", "po_vendor_delivery_date",
"possible_plant_eaf", "priority_score", "procurement_cost", "procurement_costs",
"procurement_details", "projected_eaf_improvement", "quantity",
"quantity_required", "query_str", "recommendedScope", "recommended_reduced_outage",
"reference", "reference_id", "remark", "removal_date", "required_improvement",
"results", "schedules", "scope", "scope_calculation_id", "scope_equipment_job",
"scope_name", "scope_overhaul", "service_cost", "session", "simulation",
"simulation_id", "sort_by", "sortBy[]", "descending[]", "exclude[]",
"sparepart_id", "sparepart_impact", "sparepart_name", "sparepart_summary",
"spreadsheet_link", "start", "start_date", "status", "subsystem", "system",
"systemComponents", "target_plant_eaf", "tasks", "timing_recommendation",
"total", "totalPages", "total_cost", "total_equipment", "total_equipment_analyzed",
"total_procurement_items", "type", "unit_cost", "warning_message", "with_results",
"workscope", "workscope_group", "year", "_", "t", "timestamp",
"q", "filter", "currentUser", "risk_cost", "all", "with_results",
"eaf_threshold", "simulation_id", "scope_calculation_id", "calculation_id"
}
ALLOWED_HEADERS = {
"host",
"user-agent",
"accept",
"accept-language",
"accept-encoding",
"connection",
"upgrade-insecure-requests",
"if-modified-since",
"if-none-match",
"cache-control",
"authorization",
"content-type",
"content-length",
"origin",
"referer",
"sec-fetch-dest",
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-user",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"pragma",
"dnt",
"priority",
"x-forwarded-for",
"x-forwarded-proto",
"x-forwarded-host",
"x-forwarded-port",
"x-real-ip",
"x-request-id",
"x-correlation-id",
"x-requested-with",
"x-csrf-token",
"x-xsrf-token",
"postman-token",
"x-forwarded-path",
"x-forwarded-prefix",
"cookie",
"x-kong-request-id"
}
ALLOWED_MULTI_PARAMS = {'sortBy[]', 'descending[]', 'exclude[]', 'assetnums', 'plant_ids', 'job_ids'}
ALLOWED_DATA_PARAMS = {'actual_shutdown', 'all_params', 'analysis_metadata', 'asset_contributions', 'assetnum', 'assetnums', 'assigned_date', 'availability', 'availableScopes', 'avg_cost', 'birbaum', 'calculation_type', 'capacity_weight', 'code', 'contribution', 'corrective_cost', 'corrective_costs', 'cost', 'costPerFailure', 'cost_savings_vs_planned', 'cost_threshold', 'cost_trend', 'created_at', 'crew_number', 'criticalParts', 'critical_procurement_items', 'current_eaf', 'current_plant_eaf', 'current_stock', 'current_user', 'cut_hours', 'daily_failures', 'data', 'datetime', 'day', 'days', 'descending', 'description', 'down_time', 'duration', 'duration_oh', 'eaf_gap', 'eaf_improvement_text', 'eaf_input', 'efficiency', 'end_date', 'equipment_name', 'equipment_results', 'equipment_with_sparepart_constraints', 'exclude', 'excluded_equipment', 'expected_delivery_date', 'filter_spec', 'finish', 'fleet_statistics', 'id', 'improvement_impact', 'included_equipment', 'included_in_optimization', 'intervalDays', 'is_included', 'itemnum', 'items', 'itemsPerPage', 'items_per_page', 'job', 'job_ids', 'last_overhaul_date', 'lead_time', 'location', 'location_tag', 'location_tags', 'maintenance_type', 'master_equipment', 'material_cost', 'max_interval', 'max_interval_months', 'message', 'month', 'months_from_planned', 'name', 'next_planned_overhaul', 'node', 'num_failures', 'num_of_failures', 'ohSessionId', 'oh_scope', 'oh_session_id', 'oh_type', 'oh_types', 'optimal_analysis', 'optimal_breakdown', 'optimal_month', 'optimal_total_cost', 'optimization_success', 'optimum_analysis', 'optimum_day', 'optimum_oh', 'optimum_oh_day', 'optimum_oh_month', 'order_date', 'overhaulCost', 'overhaul_activity', 'overhaul_cost', 'overhaul_costs', 'overhaul_reference_type', 'overhaul_scope', 'overhaul_scope_id', 'overview', 'page', 'parent', 'parent_id', 'plan_duration', 'planned_month', 'planned_outage', 'plant_level_benefit', 'po_pr_id', 'po_vendor_delivery_date', 'possible_plant_eaf', 'priority_score', 'procurement_cost', 'procurement_costs', 'procurement_details', 'projected_eaf_improvement', 'quantity', 'quantity_required', 'query_str', 'recommendedScope', 'recommended_reduced_outage', 'reference', 'reference_id', 'remark', 'removal_date', 'required_improvement', 'results', 'schedules', 'scope', 'scope_calculation_id', 'scope_equipment_job', 'scope_name', 'scope_overhaul', 'service_cost', 'session', 'simulation', 'simulation_id', 'sort_by', 'sortBy[]', 'descending[]', 'exclude[]', 'sparepart_id', 'sparepart_impact', 'sparepart_name', 'sparepart_summary', 'spreadsheet_link', 'start', 'start_date', 'status', 'subsystem', 'system', 'systemComponents', 'target_plant_eaf', 'tasks', 'timing_recommendation', 'total', 'totalPages', 'total_cost', 'total_equipment', 'total_equipment_analyzed', 'total_procurement_items', 'type', 'unit_cost', 'warning_message', 'with_results', 'workscope', 'workscope_group', 'year', '_', 't', 'timestamp', 'q', 'filter', 'currentUser', 'risk_cost', 'all', 'with_results', 'eaf_threshold', 'simulation_id', 'scope_calculation_id', 'calculation_id'}
ALLOWED_HEADERS = {'host', 'user-agent', 'accept', 'accept-language', 'accept-encoding', 'connection', 'upgrade-insecure-requests', 'if-modified-since', 'if-none-match', 'cache-control', 'authorization', 'content-type', 'content-length', 'origin', 'referer', 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-user', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'pragma', 'dnt', 'priority', 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port', 'x-real-ip', 'x-request-id', 'x-correlation-id', 'x-requested-with', 'x-csrf-token', 'x-xsrf-token', 'postman-token', 'x-forwarded-path', 'x-forwarded-prefix', 'cookie', 'x-kong-request-id'}
MAX_QUERY_PARAMS = 50
MAX_QUERY_LENGTH = 2000
MAX_JSON_BODY_SIZE = 1024 * 500 # 500 KB
XSS_PATTERN = re.compile(
r"("
r"<(script|iframe|embed|object|svg|img|video|audio|base|link|meta|form|button|details|animate)\b|"
r"javascript\s*:|vbscript\s*:|data\s*:[^,]*base64[^,]*|data\s*:text/html|"
r"\bon[a-z]+\s*=|" # Catch-all for any 'on' event (onerror, onclick, etc.)
r"style\s*=.*expression\s*\(|" # Old IE specific
r"\b(eval|setTimeout|setInterval|Function)\s*\("
r")",
re.IGNORECASE,
)
SQLI_PATTERN = re.compile(
r"("
# 1. Keywords followed by whitespace and common SQL characters
r"\b(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC(UTE)?|DECLARE)\b\s+[\w\*\(\']|"
# 2. Time-based attacks (more specific than just 'SLEEP')
r"\b(WAITFOR\b\s+DELAY|PG_SLEEP|SLEEP\s*\()|"
# 3. System tables/functions
r"\b(INFORMATION_SCHEMA|SYS\.|SYSOBJECTS|XP_CMDSHELL|LOAD_FILE|INTO\s+OUTFILE)\b|"
# 4. Logical Tautologies (OR 1=1) - Optimized for boundaries
r"\b(OR|AND)\b\s+['\"]?\d+['\"]?\s*=\s*['\"]?\d+|"
# 5. Comments
# Match '--' if at start or preceded by whitespace
r"(?<!\S)--|"
# Match block comments, ensuring they aren't part of mime patterns like */*
r"(?<!\*)/\*|(?<!\*)\*/(?!\*)|"
# Match '#' if at start or preceded by whitespace
r"(?<!\S)#|"
# 6. Hex / Stacked Queries
r";\s*\b(SELECT|DROP|DELETE|UPDATE|INSERT)\b"
r")",
re.IGNORECASE
)
RCE_PATTERN = re.compile(
r"("
r"\$\(.*\)|`.*`|" # Command substitution $(...) or `...`
r"[;&|]\s*(cat|ls|id|whoami|pwd|ifconfig|ip|netstat|nc|netcat|nmap|curl|wget|python|php|perl|ruby|bash|sh|cmd|powershell|pwsh|sc\s+|tasklist|taskkill|base64|sudo|crontab|ssh|ftp|tftp)|"
# Only flag naked commands if they are clearly standalone or system paths
r"\b(/etc/passwd|/etc/shadow|/etc/group|/etc/issue|/proc/self/|/windows/system32/|C:\\Windows\\)\b"
r")",
re.IGNORECASE,
)
TRAVERSAL_PATTERN = re.compile(
r"(\.\.[/\\]|%2e%2e%2f|%2e%2e/|\.\.%2f|%2e%2e%5c|%252e%252e%252f|\\00)",
re.IGNORECASE,
)
FORBIDDEN_JSON_KEYS = {"__proto__", "constructor", "prototype"}
DYNAMIC_KEYS = {
"data",
"results",
"analysis_metadata",
"asset_contributions",
"equipment_results",
"optimal_analysis",
"optimum_analysis",
"schedules",
"tasks",
"all_params",
"parameters",
"program_data"
}
log = logging.getLogger("security_logger")
MAX_JSON_BODY_SIZE = 1024 * 500
XSS_PATTERN = re.compile('(<(script|iframe|embed|object|svg|img|video|audio|base|link|meta|form|button|details|animate)\\b|javascript\\s*:|vbscript\\s*:|data\\s*:[^,]*base64[^,]*|data\\s*:text/html|\\bon[a-z]+\\s*=|style\\s*=.*expression\\s*\\(|\\b(eval|setTimeout|setInterval|Function)\\s*\\()', re.IGNORECASE)
SQLI_PATTERN = re.compile('(\\b(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC(UTE)?|DECLARE)\\b\\s+[\\w\\*\\(\\\']|\\b(WAITFOR\\b\\s+DELAY|PG_SLEEP|SLEEP\\s*\\()|\\b(INFORMATION_SCHEMA|SYS\\.|SYSOBJECTS|XP_CMDSHELL|LOAD_FILE|INTO\\s+OUTFILE)\\b|\\b(OR|AND)\\b\\s+[\'\\"]?\\d+[\'\\"]?\\s*=\\s*[\'\\"]?\\d+|(?<!\\S)--|(?<!\\*)/\\*|(?<!\\*)\\*/(?!\\*)|(?<!\\S)#|;\\s*\\b(SELECT|DROP|DELETE|UPDATE|INSERT)\\b)', re.IGNORECASE)
RCE_PATTERN = re.compile('(\\$\\(.*\\)|`.*`|[;&|]\\s*(cat|ls|id|whoami|pwd|ifconfig|ip|netstat|nc|netcat|nmap|curl|wget|python|php|perl|ruby|bash|sh|cmd|powershell|pwsh|sc\\s+|tasklist|taskkill|base64|sudo|crontab|ssh|ftp|tftp)|\\b(/etc/passwd|/etc/shadow|/etc/group|/etc/issue|/proc/self/|/windows/system32/|C:\\\\Windows\\\\)\\b)', re.IGNORECASE)
TRAVERSAL_PATTERN = re.compile('(\\.\\.[/\\\\]|%2e%2e%2f|%2e%2e/|\\.\\.%2f|%2e%2e%5c|%252e%252e%252f|\\\\00)', re.IGNORECASE)
FORBIDDEN_JSON_KEYS = {'__proto__', 'constructor', 'prototype'}
DYNAMIC_KEYS = {'data', 'results', 'analysis_metadata', 'asset_contributions', 'equipment_results', 'optimal_analysis', 'optimum_analysis', 'schedules', 'tasks', 'all_params', 'parameters', 'program_data'}
log = logging.getLogger('security_logger')
def has_control_chars(value: str) -> bool:
return any(ord(c) < 32 and c not in ("\n", "\r", "\t") for c in value)
return any((ord(c) < 32 and c not in ('\n', '\r', '\t') for c in value))
def inspect_value(value: str, source: str):
if not isinstance(value, str) or value == "*/*":
if not isinstance(value, str) or value == '*/*':
return
if XSS_PATTERN.search(value):
raise HTTPException(
status_code=422,
detail=f"XSS payload detected in {source}",
)
raise HTTPException(status_code=422, detail=f'XSS payload detected in {source}')
if SQLI_PATTERN.search(value):
raise HTTPException(
status_code=422,
detail=f"SQL injection payload detected in {source}",
)
raise HTTPException(status_code=422, detail=f'SQL injection payload detected in {source}')
if RCE_PATTERN.search(value):
raise HTTPException(
status_code=422,
detail=f"Remote code execution payload detected in {source}",
)
raise HTTPException(status_code=422, detail=f'Remote code execution payload detected in {source}')
if TRAVERSAL_PATTERN.search(value):
raise HTTPException(
status_code=422,
detail=f"Path traversal payload detected in {source}",
)
raise HTTPException(status_code=422, detail=f'Path traversal payload detected in {source}')
if has_control_chars(value):
raise HTTPException(
status_code=422,
detail=f"Invalid control characters detected in {source}",
)
raise HTTPException(status_code=422, detail=f'Invalid control characters detected in {source}')
def inspect_json(obj, path="body", check_whitelist=True):
def inspect_json(obj, path='body', check_whitelist=True):
if isinstance(obj, dict):
for key, value in obj.items():
if key in FORBIDDEN_JSON_KEYS:
raise HTTPException(
status_code=422,
detail=f"Forbidden JSON key detected: {path}.{key}",
)
raise HTTPException(status_code=422, detail=f'Forbidden JSON key detected: {path}.{key}')
if check_whitelist and key not in ALLOWED_DATA_PARAMS:
raise HTTPException(
status_code=422,
detail=f"Unknown JSON key detected: {path}.{key}",
)
# Recurse. If the key is a dynamic container, we stop whitelist checking for children.
should_check_subkeys = check_whitelist and (key not in DYNAMIC_KEYS)
inspect_json(value, f"{path}.{key}", check_whitelist=should_check_subkeys)
raise HTTPException(status_code=422, detail=f'Unknown JSON key detected: {path}.{key}')
should_check_subkeys = check_whitelist and key not in DYNAMIC_KEYS
inspect_json(value, f'{path}.{key}', check_whitelist=should_check_subkeys)
elif isinstance(obj, list):
for i, item in enumerate(obj):
inspect_json(item, f"{path}[{i}]", check_whitelist=check_whitelist)
inspect_json(item, f'{path}[{i}]', check_whitelist=check_whitelist)
elif isinstance(obj, str):
inspect_value(obj, path)
# =========================
# Middleware
# =========================
class RequestValidationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Wrap the entire dispatch body so that any HTTPException raised during
# validation is handled by our central handle_exception rather than
# escaping through call_next into the outer middleware chain.
# Without this wrapper, exceptions from dispatch() bypass db_session_middleware's
# logging code and cause an all-nulls ERROR log from uvicorn's internal logger.
try:
return await self._validate_and_forward(request, call_next)
except HTTPException as exc:
@ -266,218 +60,86 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
return await handle_exception(request, exc)
async def _validate_and_forward(self, request: Request, call_next):
# # -------------------------
# # 0. Header validation
# # -------------------------
# header_keys = [key.lower() for key, _ in request.headers.items()]
# # Check for duplicate headers
# header_counter = Counter(header_keys)
# duplicate_headers = [key for key, count in header_counter.items() if count > 1]
# ALLOW_DUPLICATE_HEADERS = {'accept', 'accept-encoding', 'accept-language', 'accept-charset', 'cookie'}
# real_duplicates = [h for h in duplicate_headers if h not in ALLOW_DUPLICATE_HEADERS]
# if real_duplicates:
# raise HTTPException(
# status_code=422,
# detail=f"Duplicate headers detected: {real_duplicates}",
# )
# # Whitelist headers
# unknown_headers = [key for key in header_keys if key not in ALLOWED_HEADERS]
# if unknown_headers:
# filtered_unknown = [h for h in unknown_headers if not h.startswith('sec-')]
# if filtered_unknown:
# raise HTTPException(
# status_code=422,
# detail=f"Unknown headers detected: {filtered_unknown}",
# )
# # Inspect header values
# for key, value in request.headers.items():
# if value:
# inspect_value(value, f"header '{key}'")
# -------------------------
# 1. Query string limits
# -------------------------
if len(request.url.query) > MAX_QUERY_LENGTH:
raise HTTPException(
status_code=422,
detail="Query string too long",
)
raise HTTPException(status_code=422, detail='Query string too long')
params = request.query_params.multi_items()
if len(params) > MAX_QUERY_PARAMS:
raise HTTPException(
status_code=422,
detail="Too many query parameters",
)
# Check for unknown query parameters
raise HTTPException(status_code=422, detail='Too many query parameters')
unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS]
if unknown_params:
raise HTTPException(
status_code=422,
detail=f"Unknown query parameters detected: {unknown_params}",
)
# -------------------------
# 2. Duplicate parameters
# -------------------------
counter = Counter(key for key, _ in params)
duplicates = [
key for key, count in counter.items()
if count > 1 and key not in ALLOWED_MULTI_PARAMS
]
raise HTTPException(status_code=422, detail=f'Unknown query parameters detected: {unknown_params}')
counter = Counter((key for key, _ in params))
duplicates = [key for key, count in counter.items() if count > 1 and key not in ALLOWED_MULTI_PARAMS]
if duplicates:
raise HTTPException(
status_code=422,
detail=f"Duplicate query parameters: {duplicates}",
)
# -------------------------
# 3. Query param inspection & Pagination
# -------------------------
pagination_size_keys = {"size", "itemsPerPage", "per_page", "limit", "items_per_page"}
raise HTTPException(status_code=422, detail=f'Duplicate query parameters: {duplicates}')
pagination_size_keys = {'size', 'itemsPerPage', 'per_page', 'limit', 'items_per_page'}
for key, value in params:
if value:
inspect_value(value, f"query param '{key}'")
if key in pagination_size_keys and value:
try:
size_val = int(value)
if size_val > 50:
raise HTTPException(
status_code=422,
detail=f"Pagination size too large: {size_val} (max 50)",
)
raise HTTPException(status_code=422, detail=f'Pagination size too large: {size_val} (max 50)')
if size_val % 5 != 0:
raise HTTPException(
status_code=422,
detail=f"Pagination size not a multiple of 5: {size_val}",
)
raise HTTPException(status_code=422, detail=f'Pagination size not a multiple of 5: {size_val}')
except ValueError:
raise HTTPException(
status_code=422,
detail=f"Pagination size invalid value for '{key}'",
)
# -------------------------
# 4. Content-Type sanity
# -------------------------
content_type = request.headers.get("content-type", "")
if content_type and not any(
content_type.startswith(t)
for t in ("application/json", "multipart/form-data", "application/x-www-form-urlencoded")
):
raise HTTPException(status_code=422, detail=f"Unsupported Content-Type: {content_type}")
# -------------------------
# 5. Single source check (Query vs JSON Body)
# -------------------------
raise HTTPException(status_code=422, detail=f"Pagination size invalid value for '{key}'")
content_type = request.headers.get('content-type', '')
if content_type and (not any((content_type.startswith(t) for t in ('application/json', 'multipart/form-data', 'application/x-www-form-urlencoded')))):
raise HTTPException(status_code=422, detail=f'Unsupported Content-Type: {content_type}')
has_query = len(params) > 0
has_body = False
if content_type.startswith("application/json"):
# We can't easily check body existence without consuming it,
# so we check if Content-Length > 0
content_length = request.headers.get("content-length")
if content_type.startswith('application/json'):
content_length = request.headers.get('content-length')
if content_length and int(content_length) > 0:
has_body = True
if has_query and has_body:
raise HTTPException(
status_code=422,
detail="Mixed parameters: both query string and JSON body are not allowed",
)
# -------------------------
# 6. JSON body inspection
# -------------------------
if content_type.startswith("application/json"):
raise HTTPException(status_code=422, detail='Mixed parameters: both query string and JSON body are not allowed')
if content_type.startswith('application/json'):
body = await request.body()
# if len(body) > MAX_JSON_BODY_SIZE:
# raise HTTPException(status_code=422, detail="JSON body too large")
if body:
try:
payload = json.loads(body)
except json.JSONDecodeError:
# Malformed JSON is a client-side structural error → 400 Bad Request,
# NOT 422 Unprocessable Entity (which is for semantically invalid input).
raise HTTPException(status_code=400, detail="Invalid JSON body: malformed JSON")
raise HTTPException(status_code=400, detail='Invalid JSON body: malformed JSON')
inspect_json(payload)
# Re-inject body for downstream handlers
async def receive():
return {"type": "http.request", "body": body}
return {'type': 'http.request', 'body': body}
request._receive = receive
return await call_next(request)
import threading
import asyncio
import json
import logging
REQUEST_TIMEOUT_SECONDS = 20
log = logging.getLogger("security_logger")
log = logging.getLogger('security_logger')
class RequestTimeoutMiddleware:
"""
ASGI middleware that cancels requests exceeding REQUEST_TIMEOUT_SECONDS.
Returns HTTP 408 Request Timeout with a JSON body when threshold is exceeded.
"""
def __init__(self, app, timeout: int=REQUEST_TIMEOUT_SECONDS):
self.app = app
self.timeout = timeout
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
if scope['type'] != 'http':
await self.app(scope, receive, send)
return
response_started = False
async def send_wrapper(message):
nonlocal response_started
if message["type"] == "http.response.start":
if message['type'] == 'http.response.start':
response_started = True
await send(message)
try:
await asyncio.wait_for(
self.app(scope, receive, send_wrapper),
timeout=self.timeout,
)
await asyncio.wait_for(self.app(scope, receive, send_wrapper), timeout=self.timeout)
except asyncio.TimeoutError:
if not response_started:
import uuid as _uuid
error_id = str(_uuid.uuid4())
# Flag the scope so db_session_middleware can log the 408 with correct fields
scope["x_timed_out"] = True
scope["x_timeout_error_id"] = error_id
body = json.dumps({
"data": None,
"message": f"Request timeout. Error ID: {error_id}",
"status": False,
}).encode("utf-8")
await send({
"type": "http.response.start",
"status": 408,
"headers": [
[b"content-type", b"application/json; charset=utf-8"],
[b"content-length", str(len(body)).encode()],
],
})
await send({
"type": "http.response.body",
"body": body,
})
scope['x_timed_out'] = True
scope['x_timeout_error_id'] = error_id
body = json.dumps({'data': None, 'message': f'Request timeout. Error ID: {error_id}', 'status': False}).encode('utf-8')
await send({'type': 'http.response.start', 'status': 408, 'headers': [[b'content-type', b'application/json; charset=utf-8'], [b'content-length', str(len(body)).encode()]]})
await send({'type': 'http.response.body', 'body': body})

@ -1,31 +1,17 @@
# src/common/models.py
import uuid
from datetime import datetime
from typing import Generic, Optional, TypeVar
import pytz
from pydantic import BaseModel, Field, SecretStr
from sqlalchemy import Column, DateTime, String, event, func
from pydantic import BaseModel, SecretStr
from sqlalchemy import Column, DateTime, String, event
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from src.auth.service import CurrentUser
from src.config import TIMEZONE
from src.enums import ResponseStatus
# SQLAlchemy Mixins
class TimeStampMixin(object):
"""Timestamping mixin"""
created_at = Column(
DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))
)
created_at = Column(DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE)))
created_at._creation_order = 9998
updated_at = Column(
DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))
)
updated_at = Column(DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE)))
updated_at._creation_order = 9998
@staticmethod
@ -34,57 +20,32 @@ class TimeStampMixin(object):
@classmethod
def __declare_last__(cls):
event.listen(cls, "before_update", cls._updated_at)
event.listen(cls, 'before_update', cls._updated_at)
class UUIDMixin:
"""UUID mixin"""
id = Column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
unique=True,
nullable=False,
)
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, unique=True, nullable=False)
class SoftDeleteMixin:
"""Soft delete mixin — adds deleted_at column to mark records as deleted."""
deleted_at = Column(DateTime(timezone=True), nullable=True, default=None)
deleted_at._creation_order = 9999
class DefaultMixin(SoftDeleteMixin, TimeStampMixin, UUIDMixin):
"""Default mixin"""
pass
class IdentityMixin:
"""Identity mixin"""
created_by = Column(String(100), nullable=True)
updated_by = Column(String(100), nullable=True)
# Pydantic Models
class DefultBase(BaseModel):
class Config:
from_attributes = True
validate_assignment = True
arbitrary_types_allowed = True
str_strip_whitespace = True
populate_by_name = True
extra="forbid"
json_encoders = {
# custom output conversion for datetime
datetime: lambda v: v.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if v else None,
SecretStr: lambda v: v.get_secret_value() if v else None,
}
extra = 'forbid'
json_encoders = {datetime: lambda v: v.strftime('%Y-%m-%dT%H:%M:%S.%fZ') if v else None, SecretStr: lambda v: v.get_secret_value() if v else None}
class Pagination(DefultBase):
itemsPerPage: int
@ -92,16 +53,11 @@ class Pagination(DefultBase):
page: int
total: int
class PrimaryKeyModel(BaseModel):
id: uuid.UUID
# Define data type variable for generic response
T = TypeVar("T")
T = TypeVar('T')
class StandardResponse(BaseModel, Generic[T]):
data: Optional[T] = None
message: str = "Success"
message: str = 'Success'
status: ResponseStatus = ResponseStatus.SUCCESS

@ -1,72 +1,35 @@
from typing import List
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter
from src.auth.service import Token
from src.database.core import CollectorDbSession, DbSession
from src.models import StandardResponse
from src.overhaul.service import (get_overhaul_critical_parts,
get_overhaul_overview,
get_overhaul_schedules,
get_overhaul_system_components)
from src.overhaul.service import get_overhaul_critical_parts, get_overhaul_overview, get_overhaul_schedules, get_overhaul_system_components
from src.overhaul_scope.schema import ScopeRead
from .schema import (OverhaulCriticalParts, OverhaulRead,
OverhaulSystemComponents)
from .schema import OverhaulCriticalParts, OverhaulRead, OverhaulSystemComponents
from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends
router = APIRouter()
@router.get("", response_model=StandardResponse[OverhaulRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get('', response_model=StandardResponse[OverhaulRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_overhaul(db_session: DbSession, token: Token, collector_db_session: CollectorDbSession):
"""Get all scope pagination."""
overview = await get_overhaul_overview(db_session=db_session)
schedules = await get_overhaul_schedules(db_session=db_session)
criticalParts = await get_overhaul_critical_parts(db_session=db_session, session_id=overview["overhaul"]["id"], token=token, collector_db_session=collector_db_session)
criticalParts = await get_overhaul_critical_parts(db_session=db_session, session_id=overview['overhaul']['id'], token=token, collector_db_session=collector_db_session)
systemComponents = get_overhaul_system_components()
return StandardResponse(data=OverhaulRead(overview=overview, schedules=schedules, criticalParts=criticalParts, systemComponents=systemComponents), message='Data retrieved successfully')
return StandardResponse(
data=OverhaulRead(
overview=overview,
schedules=schedules,
criticalParts=criticalParts,
systemComponents=systemComponents,
),
message="Data retrieved successfully",
)
@router.get("/schedules", response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get('/schedules', response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_schedules():
"""Get all overhaul schedules."""
schedules = get_overhaul_schedules()
return StandardResponse(
data=schedules,
message="Data retrieved successfully",
)
return StandardResponse(data=schedules, message='Data retrieved successfully')
@router.get("/critical-parts", response_model=StandardResponse[OverhaulCriticalParts], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get('/critical-parts', response_model=StandardResponse[OverhaulCriticalParts], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_critical_parts():
"""Get all overhaul critical parts."""
criticalParts = get_overhaul_critical_parts()
return StandardResponse(
data=OverhaulCriticalParts(criticalParts=criticalParts),
message="Data retrieved successfully",
)
return StandardResponse(data=OverhaulCriticalParts(criticalParts=criticalParts), message='Data retrieved successfully')
@router.get(
"/system-components", response_model=StandardResponse[OverhaulSystemComponents],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
@router.get('/system-components', response_model=StandardResponse[OverhaulSystemComponents], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_system_components():
"""Get all overhaul system components."""
systemComponents = get_overhaul_system_components()
return StandardResponse(
data=OverhaulSystemComponents(systemComponents=systemComponents),
message="Data retrieved successfully",
)
return StandardResponse(data=OverhaulSystemComponents(systemComponents=systemComponents), message='Data retrieved successfully')

@ -1,72 +1,21 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from typing import Any, Dict, List
from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead
class OverhaulBase(BaseModel):
pass
class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description="List of critical parts")
criticalParts: List[str] = Field(..., description='List of critical parts')
class OverhaulSchedules(OverhaulBase):
schedules: List[Dict[str, Any]] = Field(..., description="List of schedules")
schedules: List[Dict[str, Any]] = Field(..., description='List of schedules')
class OverhaulSystemComponents(OverhaulBase):
systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
systemComponents: Dict[str, Any] = Field(..., description='List of system components')
class OverhaulRead(OverhaulBase):
overview: Dict[str, Any]
criticalParts: dict
schedules: List[ScopeRead]
systemComponents: Dict[str, Any]
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,355 +1,51 @@
import asyncio
from typing import Optional
import httpx
from sqlalchemy import Delete, Select
from src.auth.service import CurrentUser
from sqlalchemy import Select
from src.calculation_target_reliability.service import RBD_SERVICE_API
from src.config import TC_RBD_ID
from src.database.core import DbSession
from src.contribution_util import calculate_contribution
from src.overhaul_activity.service import get_standard_scope_by_session_id
from src.overhaul_scope.model import OverhaulScope
from src.overhaul_scope.service import get_all as get_all_session
from src.overhaul_scope.service import get_overview_overhaul
from src.standard_scope.service import get_by_oh_session_id
async def get_overhaul_overview(db_session: DbSession):
"""Get all overhaul overview."""
results = await get_overview_overhaul(db_session=db_session)
return results
return await get_overview_overhaul(db_session=db_session)
async def get_simulation_results(*, simulation_id: str, token: str):
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode"
# plot_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/plot/{simulation_id}?nodetype=RegularNode"
calc_plant_result = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant"
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'}
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode'
calc_plant_result = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant'
async with httpx.AsyncClient(timeout=300.0) as client:
calc_task = client.get(calc_result_url, headers=headers)
# plot_task = client.get(plot_result_url, headers=headers)
plant_task = client.get(calc_plant_result, headers=headers)
# Run all three requests concurrently
calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status()
# plot_response.raise_for_status()
plant_response.raise_for_status()
calc_data = calc_response.json()["data"]
# plot_data = plot_response.json()["data"]
plant_data = plant_response.json()["data"]
return {
"calc_result": calc_data,
# "plot_result": plot_data,
"plant_result": plant_data
}
calc_data = calc_response.json()['data']
plant_data = plant_response.json()['data']
return {'calc_result': calc_data, 'plant_result': plant_data}
async def get_overhaul_critical_parts(db_session, session_id, token, collector_db_session):
"""Get all overhaul critical parts."""
equipments = await get_standard_scope_by_session_id(
db_session=db_session,
overhaul_session_id=session_id,
collector_db=collector_db_session
)
criticality_simulation = await get_simulation_results(
simulation_id = TC_RBD_ID,
token=token
)
rbd_simulation = {asset['aeros_node']["node_name"]: {
"availability": asset["availability"],
"criticality": asset["criticality"]
} for asset in criticality_simulation["calc_result"]}
# Create the base result list
base_result = [
{
"id": equipment.id,
"location_tag": equipment.location_tag,
"name": equipment.equipment_name,
"matrix": rbd_simulation.get(equipment.location_tag)
} for equipment in equipments
]
# Filter out items without matrix data (where rbd_simulation.get() returned None)
filtered_result = [item for item in base_result if item["matrix"] is not None]
# Sort by availability (lowest to highest) and limit to 10
availability_result = sorted(
filtered_result,
key=lambda x: x["matrix"]["availability"]
)[:10]
# Sort by criticality (highest to lowest) and limit to 10
criticality_result = sorted(
filtered_result,
key=lambda x: x["matrix"]["criticality"],
reverse=True
)[:10]
return {
"availability" :availability_result,
"criticality": criticality_result
}
equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=session_id, collector_db=collector_db_session)
criticality_simulation = await get_simulation_results(simulation_id=TC_RBD_ID, token=token)
rbd_simulation = {asset['aeros_node']['node_name']: {'availability': asset['availability'], 'criticality': asset['criticality']} for asset in criticality_simulation['calc_result']}
base_result = [{'id': equipment.id, 'location_tag': equipment.location_tag, 'name': equipment.equipment_name, 'matrix': rbd_simulation.get(equipment.location_tag)} for equipment in equipments]
filtered_result = [item for item in base_result if item['matrix'] is not None]
availability_result = sorted(filtered_result, key=lambda x: x['matrix']['availability'])[:10]
criticality_result = sorted(filtered_result, key=lambda x: x['matrix']['criticality'], reverse=True)[:10]
return {'availability': availability_result, 'criticality': criticality_result}
async def get_overhaul_schedules(*, db_session: DbSession):
"""Get all overhaul schedules."""
query = Select(OverhaulScope)
results = await db_session.execute(query)
return results.scalars().all()
def get_overhaul_system_components():
"""Get all overhaul system components with dummy data."""
powerplant_reliability = {
"Plant Control": {
"availability": 0.9994866529774127,
"efficiency": 0.9994956204510826,
"total_uptime": 17523.0,
},
"SPS": {
"availability": 0.9932694501483038,
"efficiency": 0.9810193821516867,
"total_uptime": 17414.000000000062,
},
"Turbine": {
"availability": 0.9931553730321733,
"efficiency": 0.9666721976783572,
"total_uptime": 17412.000000000062,
},
"Generator": {
"availability": 0.9934405658224995,
"efficiency": 0.9625424646119242,
"total_uptime": 17417.000000000062,
},
"Condensate Water": {
"availability": 0.9934405658224995,
"efficiency": 0.9531653517377116,
"total_uptime": 17417.000000000062,
},
"Feedwater System": {
"availability": 0.9936116814966953,
"efficiency": 0.9687512254370128,
"total_uptime": 17420.000000000062,
},
"Cooling Water": {
"availability": 0.99355464293863,
"efficiency": 0.9749999189617731,
"total_uptime": 17419.000000000062,
},
"SCR": {
"availability": 0.9196577686516085,
"efficiency": 0.9996690612127086,
"total_uptime": 17526.0,
},
"Ash Handling": {
"availability": 0.9931838923112059,
"efficiency": 0.9933669638506657,
"total_uptime": 17412.500000000062,
},
"Air Flue Gas": {
"availability": 0.9906456764773022,
"efficiency": 0.8565868045084128,
"total_uptime": 17368.000000000062,
},
"Boiler": {
"availability": 0.9934976043805648,
"efficiency": 0.9693239775686654,
"total_uptime": 17418.000000000062,
},
"SAC-IAC": {
"availability": 0.9936116814966953,
"efficiency": 0.9878742473840645,
"total_uptime": 17420.000000000062,
},
"KLH": {
"availability": 0.992185717545064,
"efficiency": 0.9426658166507496,
"total_uptime": 17395.000000000062,
},
"CL": {
"availability": 0.9984029203741729,
"efficiency": 0.9984546987034779,
"total_uptime": 17504.0,
},
"Desalination": {
"total_uptime": 17275.500000000062,
"availability": 0.9853696098562663,
"efficiency": 0.9118366404915063,
},
"FGD": {
"availability": 0.9933835272644342,
"efficiency": 0.9623105228919693,
"total_uptime": 17416.000000000062,
},
"CHS": {
"availability": 1.0,
"efficiency": 0.9665857756206829,
"total_uptime": 17532.0,
},
"SSB": {
"availability": 0.9933264887063691,
"efficiency": 0.993508327346586,
"total_uptime": 17415.000000000062,
},
"WTP": {
"availability": 0.9925849874515208,
"efficiency": 0.9925849874515206,
"total_uptime": 17402.000000000062,
},
}
powerplant_reliability = {'Plant Control': {'availability': 0.9994866529774127, 'efficiency': 0.9994956204510826, 'total_uptime': 17523.0}, 'SPS': {'availability': 0.9932694501483038, 'efficiency': 0.9810193821516867, 'total_uptime': 17414.000000000062}, 'Turbine': {'availability': 0.9931553730321733, 'efficiency': 0.9666721976783572, 'total_uptime': 17412.000000000062}, 'Generator': {'availability': 0.9934405658224995, 'efficiency': 0.9625424646119242, 'total_uptime': 17417.000000000062}, 'Condensate Water': {'availability': 0.9934405658224995, 'efficiency': 0.9531653517377116, 'total_uptime': 17417.000000000062}, 'Feedwater System': {'availability': 0.9936116814966953, 'efficiency': 0.9687512254370128, 'total_uptime': 17420.000000000062}, 'Cooling Water': {'availability': 0.99355464293863, 'efficiency': 0.9749999189617731, 'total_uptime': 17419.000000000062}, 'SCR': {'availability': 0.9196577686516085, 'efficiency': 0.9996690612127086, 'total_uptime': 17526.0}, 'Ash Handling': {'availability': 0.9931838923112059, 'efficiency': 0.9933669638506657, 'total_uptime': 17412.500000000062}, 'Air Flue Gas': {'availability': 0.9906456764773022, 'efficiency': 0.8565868045084128, 'total_uptime': 17368.000000000062}, 'Boiler': {'availability': 0.9934976043805648, 'efficiency': 0.9693239775686654, 'total_uptime': 17418.000000000062}, 'SAC-IAC': {'availability': 0.9936116814966953, 'efficiency': 0.9878742473840645, 'total_uptime': 17420.000000000062}, 'KLH': {'availability': 0.992185717545064, 'efficiency': 0.9426658166507496, 'total_uptime': 17395.000000000062}, 'CL': {'availability': 0.9984029203741729, 'efficiency': 0.9984546987034779, 'total_uptime': 17504.0}, 'Desalination': {'total_uptime': 17275.500000000062, 'availability': 0.9853696098562663, 'efficiency': 0.9118366404915063}, 'FGD': {'availability': 0.9933835272644342, 'efficiency': 0.9623105228919693, 'total_uptime': 17416.000000000062}, 'CHS': {'availability': 1.0, 'efficiency': 0.9665857756206829, 'total_uptime': 17532.0}, 'SSB': {'availability': 0.9933264887063691, 'efficiency': 0.993508327346586, 'total_uptime': 17415.000000000062}, 'WTP': {'availability': 0.9925849874515208, 'efficiency': 0.9925849874515206, 'total_uptime': 17402.000000000062}}
availabilities = {schematic: item['availability'] for schematic, item in powerplant_reliability.items()}
percentages = calculate_contribution(availabilities)
for schema, contribution in percentages.items():
powerplant_reliability[schema]["critical_contribution"] = contribution['criticality_importance']
# Sort the powerplant_reliability dictionary by critical_contribution in descending order
sorted_powerplant_reliability = dict(sorted(
powerplant_reliability.items(),
key=lambda x: x[1]["critical_contribution"],
reverse=True # Set to True for high to low sorting
))
return sorted_powerplant_reliability
return {
"HPT": {
"efficiency": "92%",
"work_hours": "1200",
"reliability": "96%",
},
"IPT": {
"efficiency": "91%",
"work_hours": "1100",
"reliability": "95%",
},
"LPT": {
"efficiency": "90%",
"work_hours": "1000",
"reliability": "94%",
},
"EG": {
"efficiency": "88%",
"work_hours": "950",
"reliability": "93%",
},
"boiler": {
"efficiency": "90%",
"work_hours": "1000",
"reliability": "95%",
},
"HPH1": {
"efficiency": "89%",
"work_hours": "1050",
"reliability": "94%",
},
"HPH2": {
"efficiency": "88%",
"work_hours": "1020",
"reliability": "93%",
},
"HPH3": {
"efficiency": "87%",
"work_hours": "1010",
"reliability": "92%",
},
"HPH5": {
"efficiency": "86%",
"work_hours": "980",
"reliability": "91%",
},
"HPH6": {
"efficiency": "85%",
"work_hours": "970",
"reliability": "90%",
},
"HPH7": {
"efficiency": "84%",
"work_hours": "960",
"reliability": "89%",
},
"Condensor": {
"efficiency": "83%",
"work_hours": "940",
"reliability": "88%",
},
"Deaerator": {
"efficiency": "82%",
"work_hours": "930",
"reliability": "87%",
},
}
# async def get(*, db_session: DbSession, scope_id: str) -> Optional[Scope]:
# """Returns a document based on the given document id."""
# query = Select(Scope).filter(Scope.id == scope_id)
# result = await db_session.execute(query)
# return result.scalars().one_or_none()
# async def get_all(*, db_session: DbSession):
# """Returns all documents."""
# query = Select(Scope)
# result = await db_session.execute(query)
# return result.scalars().all()
# async def create(*, db_session: DbSession, scope_id: ScopeCreate):
# """Creates a new document."""
# scope = Scope(**scope_id.model_dump())
# db_session.add(scope)
# await db_session.commit()
# return scope
# async def update(*, db_session: DbSession, scope: Scope, scope_id: ScopeUpdate):
# """Updates a document."""
# data = scope_id.model_dump()
# update_data = scope_id.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(scope, field, update_data[field])
# await db_session.commit()
# return scope
# async def delete(*, db_session: DbSession, scope_id: str):
# """Deletes a document."""
# query = Delete(Scope).where(Scope.id == scope_id)
# await db_session.execute(query)
# await db_session.commit()
powerplant_reliability[schema]['critical_contribution'] = contribution['criticality_importance']
return dict(sorted(powerplant_reliability.items(), key=lambda x: x[1]['critical_contribution'], reverse=True))
return {'HPT': {'efficiency': '92%', 'work_hours': '1200', 'reliability': '96%'}, 'IPT': {'efficiency': '91%', 'work_hours': '1100', 'reliability': '95%'}, 'LPT': {'efficiency': '90%', 'work_hours': '1000', 'reliability': '94%'}, 'EG': {'efficiency': '88%', 'work_hours': '950', 'reliability': '93%'}, 'boiler': {'efficiency': '90%', 'work_hours': '1000', 'reliability': '95%'}, 'HPH1': {'efficiency': '89%', 'work_hours': '1050', 'reliability': '94%'}, 'HPH2': {'efficiency': '88%', 'work_hours': '1020', 'reliability': '93%'}, 'HPH3': {'efficiency': '87%', 'work_hours': '1010', 'reliability': '92%'}, 'HPH5': {'efficiency': '86%', 'work_hours': '980', 'reliability': '91%'}, 'HPH6': {'efficiency': '85%', 'work_hours': '970', 'reliability': '90%'}, 'HPH7': {'efficiency': '84%', 'work_hours': '960', 'reliability': '89%'}, 'Condensor': {'efficiency': '83%', 'work_hours': '940', 'reliability': '88%'}, 'Deaerator': {'efficiency': '82%', 'work_hours': '930', 'reliability': '87%'}}

@ -1,43 +1,11 @@
from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from sqlalchemy import UUID, Column, Float, ForeignKey, String
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
from src.models import DefaultMixin
class OverhaulActivity(Base, DefaultMixin):
__tablename__ = "oh_tr_overhaul_activity"
__tablename__ = 'oh_tr_overhaul_activity'
assetnum = Column(String, nullable=True)
overhaul_scope_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_overhaul_scope.id"), nullable=False
)
overhaul_scope_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_overhaul_scope.id'), nullable=False)
material_cost = Column(Float, nullable=False, default=0)
service_cost = Column(Float, nullable=False, default=0)
status = Column(String, nullable=False, default="pending")
# equipment = relationship(
# "MasterEquipment",
# lazy="raise",
# primaryjoin="and_(OverhaulActivity.assetnum == foreign(MasterEquipment.assetnum))",
# uselist=False, # Add this if it's a one-to-one relationship
# )
# # sparepart_equipments = relationship(
# # "ScopeEquipmentPart",
# # lazy="select", # or "joined", "subquery", "dynamic" depending on your needs
# # primaryjoin="OverhaulActivity.assetnum == foreign(ScopeEquipmentPart.assetnum)",
# # uselist=True
# # )
# overhaul_scope = relationship(
# "OverhaulScope",
# lazy="raise",
# )
# overhaul_jobs = relationship(
# "OverhaulJob", back_populates="overhaul_activity", lazy="raise"
# )
status = Column(String, nullable=False, default='pending')

@ -1,119 +1,33 @@
from typing import List, Optional
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from src.csrf_protect import csrf_protect
from src.database.core import CollectorDbSession
from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.database.service import CommonParameters, DbSession
from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (OverhaulActivityCreate, OverhaulActivityPagination,
OverhaulActivityRead, OverhaulActivityUpdate)
from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session, update
from .schema import OverhaulActivityCreate, OverhaulActivityRead
from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('/{overhaul_session}', response_model=StandardResponse[dict])
async def get_scope_equipments(common: CommonParameters, overhaul_session: str, collector_db: CollectorDbSession, location_tag: Optional[str]=Query(None), scope_name: Optional[str]=Query(None)):
data = await get_all(common=common, location_tag=location_tag, scope_name=scope_name, overhaul_session_id=overhaul_session, collector_db=collector_db)
return StandardResponse(data=data, message='Data retrieved successfully')
@router.get(
"/{overhaul_session}", response_model=StandardResponse[dict]
)
async def get_scope_equipments(
common: CommonParameters,
overhaul_session: str,
collector_db: CollectorDbSession,
location_tag: Optional[str] = Query(None),
scope_name: Optional[str] = Query(None),
):
"""Get all scope activity pagination."""
# return
data = await get_all(
common=common,
location_tag=location_tag,
scope_name=scope_name,
overhaul_session_id=overhaul_session,
collector_db=collector_db,
)
return StandardResponse(
data=data,
message="Data retrieved successfully",
)
@router.post("/{overhaul_session_id}", response_model=StandardResponse[None])
async def create_overhaul_equipment(
db_session: DbSession,
collector_db_session: CollectorDbSession,
overhaul_activty_in: OverhaulActivityCreate,
overhaul_session_id: UUID,
):
activity = await add_multiple_equipment_to_session(
db_session=db_session,
collector_db=collector_db_session,
overhaul_session_id=overhaul_session_id,
location_tags=overhaul_activty_in.location_tags
)
@router.post('/{overhaul_session_id}', response_model=StandardResponse[None])
async def create_overhaul_equipment(db_session: DbSession, collector_db_session: CollectorDbSession, overhaul_activty_in: OverhaulActivityCreate, overhaul_session_id: UUID):
await add_multiple_equipment_to_session(db_session=db_session, collector_db=collector_db_session, overhaul_session_id=overhaul_session_id, location_tags=overhaul_activty_in.location_tags)
return StandardResponse(data=None, message='Data created successfully')
return StandardResponse(data=None, message="Data created successfully")
@router.get(
"/{overhaul_session}/{assetnum}",
response_model=StandardResponse[OverhaulActivityRead],
)
async def get_overhaul_equipment(
db_session: DbSession, assetnum: str, overhaul_session
):
equipment = await get(
db_session=db_session, assetnum=assetnum, overhaul_session_id=overhaul_session
)
@router.get('/{overhaul_session}/{assetnum}', response_model=StandardResponse[OverhaulActivityRead])
async def get_overhaul_equipment(db_session: DbSession, assetnum: str, overhaul_session):
equipment = await get(db_session=db_session, assetnum=assetnum, overhaul_session_id=overhaul_session)
if not equipment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return StandardResponse(data=equipment, message="Data retrieved successfully")
# @router.put(
# "/{overhaul_session}/{assetnum}",
# response_model=StandardResponse[OverhaulActivityRead],
# )
# async def update_scope(
# db_session: DbSession,
# scope_equipment_activity_in: OverhaulActivityUpdate,
# assetnum: str,
# ):
# activity = await get(db_session=db_session, assetnum=assetnum)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
return StandardResponse(data=equipment, message='Data retrieved successfully')
# if not activity:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(
# data=await update(
# db_session=db_session,
# activity=activity,
# scope_equipment_activity_in=scope_equipment_activity_in,
# ),
# message="Data updated successfully",
# )
@router.post(
"/delete/{overhaul_session}/{location_tag}",
response_model=StandardResponse[None],
dependencies=[Depends(csrf_protect)],
)
@router.post('/delete/{overhaul_session}/{location_tag}', response_model=StandardResponse[None], dependencies=[Depends(csrf_protect)])
async def delete_scope(db_session: DbSession, location_tag: str, overhaul_session: UUID):
await remove_equipment_from_session(db_session=db_session, overhaul_session_id=overhaul_session, location_tag=location_tag)
return StandardResponse(message="Data deleted successfully", data=None)
return StandardResponse(message='Data deleted successfully', data=None)

@ -1,31 +1,25 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import List, Optional
from uuid import UUID
from pydantic import Field
from src.models import DefultBase, Pagination
from src.standard_scope.schema import MasterEquipmentTree
from src.workscope_group.schema import ActivityMasterRead
class OverhaulActivityBase(DefultBase):
pass
class OverhaulActivityCreate(OverhaulActivityBase):
location_tags: List[str]
class OverhaulActivityUpdate(OverhaulActivityBase):
material_cost: Optional[float] = Field(0)
service_cost: Optional[float] = Field(0)
material_cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
service_cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
class OverhaulScope(DefultBase):
type: str
start_date: datetime
end_date: datetime
duration_oh: int
duration_oh: int = Field(..., ge=0, le=1000000)
class ScopeEquipmentJob(DefultBase):
job: ActivityMasterRead
@ -35,16 +29,12 @@ class OverhaulJob(DefultBase):
class OverhaulActivityRead(OverhaulActivityBase):
id: UUID
material_cost: Optional[float] = Field(0)
service_cost: Optional[float] = Field(0)
material_cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
service_cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
location_tag: str
equipment_name: Optional[str]
oh_scope: str
overhaul_cost: Optional[float] = Field(0)
# equipment: MasterEquipmentTree
# overhaul_scope: OverhaulScope
# overhaul_jobs: Optional[List[OverhaulJob]] = Field([])
overhaul_cost: Optional[float] = Field(0, ge=0, le=1000000000000000)
class OverhaulActivityPagination(Pagination):
items: List[OverhaulActivityRead] = []

@ -1,23 +1,14 @@
import asyncio
import datetime
from typing import List, Optional
from uuid import UUID, uuid4
from fastapi import HTTPException, status
from sqlalchemy import Delete, Select, and_, func, select
from sqlalchemy import update as sqlUpdate
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy import Select, and_, select
from sqlalchemy.orm import joinedload, selectinload
from src.auth.service import CurrentUser
from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate
from src.overhaul_activity.utils import get_material_cost, get_service_cost
from src.utils import update_model
from src.overhaul_scope.model import OverhaulScope
from src.overhaul_scope.service import get as get_session, get_prev_oh
from src.standard_scope.model import MasterEquipment, StandardScope
from src.standard_scope.service import get_by_oh_session_id
from src.workscope_group.model import MasterActivity
from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from src.overhaul_scope.model import MaintenanceType
@ -25,547 +16,183 @@ from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.service import get as get_overhaul
from src.standard_scope.model import EquipmentOHHistory
from .model import OverhaulActivity
from .schema import (OverhaulActivityCreate, OverhaulActivityRead,
OverhaulActivityUpdate)
from .schema import OverhaulActivityRead, OverhaulActivityUpdate
import json
from src.database.core import CollectorDbSession
from src.maximo.service import get_cm_cost_summary, get_oh_cost_summary
from src.soft_delete import apply_not_deleted_filter
async def get(
*, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID] = None
) -> Optional[OverhaulActivityRead]:
"""Returns a document based on the given document id."""
query = (
Select(OverhaulActivity)
.where(OverhaulActivity.assetnum == assetnum)
.options(joinedload(OverhaulActivity.equipment))
)
async def get(*, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID]=None) -> Optional[OverhaulActivityRead]:
query = Select(OverhaulActivity).where(OverhaulActivity.assetnum == assetnum).options(joinedload(OverhaulActivity.equipment))
query = apply_not_deleted_filter(query, OverhaulActivity)
if overhaul_session_id:
query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
result = await db_session.execute(query)
return result.scalar()
def get_cost_per_failute():
with open('src/overhaul_activity/cost_failure.json', 'r') as f:
data = json.load(f)
return data['data']
async def get_all(
*,
common: CommonParameters,
overhaul_session_id: UUID,
location_tag: Optional[str] = None,
scope_name: Optional[str] = None,
all: bool = False,
collector_db: CollectorDbSession
):
# query = (
# Select(OverhaulActivity)
# .where(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
# .options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent))))
# .options(selectinload(OverhaulActivity.overhaul_scope))
# .options(selectinload(OverhaulActivity.overhaul_jobs).options(joinedload(OverhaulJob.scope_equipment_job).options(joinedload(ScopeEquipmentJob.job))))
# )
# if assetnum:
# query = query.filter(OverhaulActivity.assetnum == assetnum).options(
# joinedload(OverhaulActivity.overhaul_scope)
# )
# if scope_name:
# query = query.filter(OverhaulActivity.scope_name == scope_name).options(
# joinedload(OverhaulActivity.overhaul_scope)
# )
# results = await search_filter_sort_paginate(model=query, **common)
##raise Exception(results['items'][0].equipment.parent.__dict__)
# equipments, overhaul = await get_by_oh_session_id(
# db_session=db_session, oh_session_id=overhaul_session_id
# )
async def get_all(*, common: CommonParameters, overhaul_session_id: UUID, location_tag: Optional[str]=None, scope_name: Optional[str]=None, all: bool=False, collector_db: CollectorDbSession):
overhaul = await get_overhaul(db_session=common['db_session'], overhaul_session_id=overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=common['db_session'], overhaul_session=overhaul)
query = (
Select(StandardScope)
.outerjoin(StandardScope.oh_history) # Use outerjoin to handle None values
.join(StandardScope.workscope_groups)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag)
.filter(MaintenanceType.name == overhaul.maintenance_type.name).filter(
(StandardScope.is_alternating_oh == False) |
(StandardScope.oh_history == None) |
(StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name))
).distinct()
)
query = Select(StandardScope).outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag).filter(MaintenanceType.name == overhaul.maintenance_type.name).filter((StandardScope.is_alternating_oh == False) | (StandardScope.oh_history == None) | StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name)).distinct()
query = apply_not_deleted_filter(query, StandardScope)
if location_tag:
query = query.filter(StandardScope.location_tag == location_tag)
# Use search_filter_sort_paginate for server-side pagination
# Prioritize the 'all' parameter passed to the function
common_params = {**common, "all": all or common.get("all", False)}
paginated_results = await search_filter_sort_paginate(
model=query,
**common_params
)
equipments = paginated_results["items"]
common_params = {**common, 'all': all or common.get('all', False)}
paginated_results = await search_filter_sort_paginate(model=query, **common_params)
equipments = paginated_results['items']
material_cost = await get_cm_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
overhaul_cost = await get_oh_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
results = []
for equipment in equipments:
if not equipment.master_equipment:
continue
cost = material_cost.get(equipment.location_tag, 0)
oh_cost = overhaul_cost.get(equipment.location_tag, 0)
res = OverhaulActivityRead(
id=equipment.id,
material_cost=float(cost),
service_cost=equipment.service_cost,
overhaul_cost=float(oh_cost),
location_tag=equipment.location_tag,
equipment_name=equipment.master_equipment.name,
oh_scope=overhaul.maintenance_type.name,
)
res = OverhaulActivityRead(id=equipment.id, material_cost=float(cost), service_cost=equipment.service_cost, overhaul_cost=float(oh_cost), location_tag=equipment.location_tag, equipment_name=equipment.master_equipment.name, oh_scope=overhaul.maintenance_type.name)
results.append(res)
# Return paginated structure with transformed items
return {
**paginated_results,
"items": results
}
return {**paginated_results, 'items': results}
async def get_standard_scope_by_session_id(*, db_session: DbSession, overhaul_session_id: UUID, collector_db: CollectorDbSession):
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=overhaul)
query = (
Select(StandardScope)
.outerjoin(
StandardScope.oh_history
) # Use outerjoin to handle None values
.join(StandardScope.workscope_groups)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.join(
MasterEquipment,
StandardScope.location_tag == MasterEquipment.location_tag,
)
.filter(MaintenanceType.name == overhaul.maintenance_type.name)
.filter(
(StandardScope.is_alternating_oh == False)
| (StandardScope.oh_history is None)
| (
StandardScope.oh_history.has(
EquipmentOHHistory.last_oh_type
!= overhaul.maintenance_type.name
)
)
)
.distinct()
)
query = Select(StandardScope).outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag).filter(MaintenanceType.name == overhaul.maintenance_type.name).filter((StandardScope.is_alternating_oh == False) | (StandardScope.oh_history is None) | StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name)).distinct()
data = await db_session.execute(query)
eqs = data.scalars().all()
results = []
material_cost = await get_cm_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
#service_cost = get_service_cost(scope=overhaul.maintenance_type.name, total_equipment=len(eqs))
overhaul_cost = await get_oh_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
for equipment in eqs:
cost = material_cost.get(equipment.location_tag, 0)
oh_cost = overhaul_cost.get(equipment.location_tag, 0)
res = OverhaulActivityRead(
id=equipment.id,
material_cost=float(cost),
service_cost=equipment.service_cost,
overhaul_cost=float(oh_cost),
location_tag=equipment.location_tag,
equipment_name=equipment.master_equipment.name if equipment.master_equipment else None,
oh_scope=overhaul.maintenance_type.name,
)
res = OverhaulActivityRead(id=equipment.id, material_cost=float(cost), service_cost=equipment.service_cost, overhaul_cost=float(oh_cost), location_tag=equipment.location_tag, equipment_name=equipment.master_equipment.name if equipment.master_equipment else None, oh_scope=overhaul.maintenance_type.name)
results.append(res)
return results
async def add_equipment_to_session(
*,
db_session: DbSession,
collector_db: CollectorDbSession,
overhaul_session_id: UUID,
location_tag: str
) -> Optional[StandardScope]:
"""
Add a new equipment to an existing overhaul session.
If equipment's workscope already maps to the overhaul type, skip.
Otherwise, attach a dummy workscope group for inclusion.
Args:
db_session: Database session
collector_db: Collector database session
overhaul_session_id: The UUID of the overhaul session
location_tag: The location tag of the equipment to add
Returns:
StandardScope: The newly created or updated StandardScope record, or None if skipped
"""
async def add_equipment_to_session(*, db_session: DbSession, collector_db: CollectorDbSession, overhaul_session_id: UUID, location_tag: str) -> Optional[StandardScope]:
try:
# Get the overhaul session
overhaul = await get_session(
db_session=db_session, overhaul_session_id=overhaul_session_id
)
# Check if MasterEquipment exists
master_eq_query = select(MasterEquipment).filter(
MasterEquipment.location_tag == location_tag
)
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id)
master_eq_query = select(MasterEquipment).filter(MasterEquipment.location_tag == location_tag)
master_eq_result = await db_session.execute(master_eq_query)
master_equipment = master_eq_result.scalar_one_or_none()
if not master_equipment:
print("Equipment not found in master")
print('Equipment not found in master')
return None
# Check if equipment already exists in StandardScope
existing_query = select(StandardScope).filter(
StandardScope.location_tag == location_tag
)
existing_query = select(StandardScope).filter(StandardScope.location_tag == location_tag)
existing_result = await db_session.execute(existing_query)
existing_equipment = existing_result.scalar_one_or_none()
# --- Step 1: Fetch equipment's actual workscope mappings ---
eq_workscope_query = (
select(EquipmentWorkscopeGroup)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(
WorkscopeOHType,
WorkscopeOHType.workscope_group_id == MasterActivity.id,
)
.join(
MaintenanceType,
MaintenanceType.id == WorkscopeOHType.maintenance_type_id,
)
.filter(EquipmentWorkscopeGroup.location_tag == location_tag)
)
eq_workscope_query = select(EquipmentWorkscopeGroup).join(EquipmentWorkscopeGroup.workscope_group).join(WorkscopeOHType, WorkscopeOHType.workscope_group_id == MasterActivity.id).join(MaintenanceType, MaintenanceType.id == WorkscopeOHType.maintenance_type_id).filter(EquipmentWorkscopeGroup.location_tag == location_tag)
eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all()
# --- Step 2: Check if already included via natural workscope ---
is_already_included = any(
ws.workscope_group
and any(
wot.oh_type.name == overhaul.maintenance_type.name
for wot in ws.workscope_group.oh_types
)
for ws in eq_workscopes
)
is_already_included = any((ws.workscope_group and any((wot.oh_type.name == overhaul.maintenance_type.name for wot in ws.workscope_group.oh_types)) for ws in eq_workscopes))
if is_already_included:
# Already belongs to this overhaul naturally → skip
return None
# --- Step 3: Handle existing equipment ---
if existing_equipment:
# Add dummy workscope if not already attached
dummy_workscope = await get_or_create_dummy_workscope(
db_session=db_session,
maintenance_type_name=overhaul.maintenance_type.name,
)
dummy_workscope = await get_or_create_dummy_workscope(db_session=db_session, maintenance_type_name=overhaul.maintenance_type.name)
if dummy_workscope not in existing_equipment.workscope_groups:
dummy_workscope.location_tag = location_tag
await db_session.commit()
await db_session.refresh(existing_equipment)
return existing_equipment
# --- Step 4: Create new StandardScope for fresh equipment ---
dummy_workscope = await get_or_create_dummy_workscope(
db_session=db_session,
maintenance_type_name=overhaul.maintenance_type.name,
)
new_equipment = StandardScope(
location_tag=location_tag,
is_alternating_oh=True,
assigned_date=datetime.date.today(),
)
dummy_workscope = await get_or_create_dummy_workscope(db_session=db_session, maintenance_type_name=overhaul.maintenance_type.name)
new_equipment = StandardScope(location_tag=location_tag, is_alternating_oh=True, assigned_date=datetime.date.today())
dummy_workscope.location_tag = location_tag
db_session.add(new_equipment)
await db_session.commit()
await db_session.refresh(new_equipment)
return new_equipment
except Exception as e:
print(f"Error adding equipment {location_tag}: {str(e)}")
print(f'Error adding equipment {location_tag}: {str(e)}')
await db_session.rollback()
return None
async def get_or_create_dummy_workscope(
*,
db_session: DbSession,
maintenance_type_name: str,
) -> EquipmentWorkscopeGroup:
"""
Get or create a dummy workscope group for included equipment.
Args:
db_session: Database session
maintenance_type_name: Name of the maintenance type (e.g., "A", "B")
Returns:
EquipmentWorkscopeGroup: The dummy workscope group
"""
dummy_name = f"Included Equipment Workscope - {maintenance_type_name}"
# --- Step 1: Check if dummy MasterActivity exists ---
async def get_or_create_dummy_workscope(*, db_session: DbSession, maintenance_type_name: str) -> EquipmentWorkscopeGroup:
dummy_name = f'Included Equipment Workscope - {maintenance_type_name}'
query = select(MasterActivity).filter(MasterActivity.workscope == dummy_name)
result = await db_session.execute(query)
master_activity = result.scalar_one_or_none()
if not master_activity:
master_activity = MasterActivity(workscope=dummy_name)
db_session.add(master_activity)
await db_session.commit()
await db_session.refresh(master_activity)
# --- Step 2: Ensure WorkscopeOHType link exists ---
mt_query = select(MaintenanceType).filter(MaintenanceType.name == maintenance_type_name)
mt_result = await db_session.execute(mt_query)
maintenance_type = mt_result.scalar_one()
wo_oh_type_query = select(WorkscopeOHType).filter(
and_(
WorkscopeOHType.workscope_group_id == master_activity.id,
WorkscopeOHType.maintenance_type_id == maintenance_type.id,
)
)
wo_oh_type_query = select(WorkscopeOHType).filter(and_(WorkscopeOHType.workscope_group_id == master_activity.id, WorkscopeOHType.maintenance_type_id == maintenance_type.id))
workscope_oh_type = (await db_session.execute(wo_oh_type_query)).scalar_one_or_none()
if not workscope_oh_type:
workscope_oh_type = WorkscopeOHType(
id=uuid4(),
workscope_group=master_activity,
oh_type=maintenance_type,
)
workscope_oh_type = WorkscopeOHType(id=uuid4(), workscope_group=master_activity, oh_type=maintenance_type)
db_session.add(workscope_oh_type)
await db_session.commit()
await db_session.refresh(workscope_oh_type)
# --- Step 3: Create new EquipmentWorkscopeGroup ---
equipment_workscope_group = EquipmentWorkscopeGroup(
workscope_group=master_activity,
)
equipment_workscope_group = EquipmentWorkscopeGroup(workscope_group=master_activity)
db_session.add(equipment_workscope_group)
await db_session.commit()
await db_session.refresh(equipment_workscope_group)
return equipment_workscope_group
async def add_multiple_equipment_to_session(
*,
db_session: DbSession,
collector_db: CollectorDbSession,
overhaul_session_id: UUID,
location_tags: List[str]
) -> List[StandardScope]:
"""
Add multiple equipment to an existing overhaul session.
Silently skips equipment that already exist or have errors.
Args:
db_session: Database session
collector_db: Collector database session
overhaul_session_id: The UUID of the overhaul session
location_tags: List of location tags to add
Returns:
List[StandardScope]: List of newly created/updated StandardScope records
"""
async def add_multiple_equipment_to_session(*, db_session: DbSession, collector_db: CollectorDbSession, overhaul_session_id: UUID, location_tags: List[str]) -> List[StandardScope]:
results = []
for location_tag in location_tags:
equipment = await add_equipment_to_session(
db_session=db_session,
collector_db=collector_db,
overhaul_session_id=overhaul_session_id,
location_tag=location_tag
)
equipment = await add_equipment_to_session(db_session=db_session, collector_db=collector_db, overhaul_session_id=overhaul_session_id, location_tag=location_tag)
if equipment:
results.append(equipment)
return results
async def get_all_by_session_id(*, db_session: DbSession, overhaul_session_id):
query = (
Select(OverhaulActivity)
.where(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
.options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent))))
.options(selectinload(OverhaulActivity.overhaul_scope))
)
query = Select(OverhaulActivity).where(OverhaulActivity.overhaul_scope_id == overhaul_session_id).options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent)))).options(selectinload(OverhaulActivity.overhaul_scope))
query = apply_not_deleted_filter(query, OverhaulActivity)
results = await db_session.execute(query)
return results.scalars().all()
async def update(
*,
db_session: DbSession,
activity_id: str,
overhaul_session_id: Optional[UUID] = None,
overhaul_activity_in: OverhaulActivityUpdate
):
"""Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update."""
query = (
Select(OverhaulActivity)
.where(OverhaulActivity.assetnum == activity_id)
.options(joinedload(OverhaulActivity.equipment))
.with_for_update()
)
async def update(*, db_session: DbSession, activity_id: str, overhaul_session_id: Optional[UUID]=None, overhaul_activity_in: OverhaulActivityUpdate):
query = Select(OverhaulActivity).where(OverhaulActivity.assetnum == activity_id).options(joinedload(OverhaulActivity.equipment)).with_for_update()
query = apply_not_deleted_filter(query, OverhaulActivity)
if overhaul_session_id:
query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
result = await db_session.execute(query)
activity = result.scalar()
if not activity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
await db_session.refresh(activity)
update_data = overhaul_activity_in.model_dump(exclude_defaults=True)
update_model(activity, update_data)
await db_session.commit()
return activity
async def remove_equipment_from_session(
*,
db_session: DbSession,
overhaul_session_id: UUID,
location_tag: str
) -> bool:
"""
Remove equipment from a given overhaul session.
Only removes dummy workscope group links; natural workscope mappings are preserved.
Args:
db_session: Database session
overhaul_session_id: The UUID of the overhaul session
location_tag: The location tag of the equipment to remove
Returns:
bool: True if removed, False if skipped (not found or naturally included)
"""
async def remove_equipment_from_session(*, db_session: DbSession, overhaul_session_id: UUID, location_tag: str) -> bool:
try:
# Get the overhaul session
overhaul = await get_session(
db_session=db_session, overhaul_session_id=overhaul_session_id
)
# Find the equipment in StandardScope
existing_query = select(StandardScope).filter(
StandardScope.location_tag == location_tag
)
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id)
existing_query = select(StandardScope).filter(StandardScope.location_tag == location_tag)
existing_result = await db_session.execute(existing_query)
equipment = existing_result.scalar_one_or_none()
if not equipment:
print(f"Equipment {location_tag} not found in StandardScope")
print(f'Equipment {location_tag} not found in StandardScope')
return False
# --- Step 1: Check if equipment belongs naturally (exclude dummy groups) ---
eq_workscope_query = (
select(EquipmentWorkscopeGroup)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(
WorkscopeOHType,
WorkscopeOHType.workscope_group_id == MasterActivity.id,
)
.join(
MaintenanceType,
MaintenanceType.id == WorkscopeOHType.maintenance_type_id,
)
.filter(EquipmentWorkscopeGroup.location_tag == location_tag)
.filter(~MasterActivity.workscope.like("Included Equipment Workscope%")) # 🚨 exclude dummy
)
eq_workscope_query = select(EquipmentWorkscopeGroup).join(EquipmentWorkscopeGroup.workscope_group).join(WorkscopeOHType, WorkscopeOHType.workscope_group_id == MasterActivity.id).join(MaintenanceType, MaintenanceType.id == WorkscopeOHType.maintenance_type_id).filter(EquipmentWorkscopeGroup.location_tag == location_tag).filter(~MasterActivity.workscope.like('Included Equipment Workscope%'))
eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all()
is_natural_inclusion = any(
ws.workscope_group
and any(
wot.oh_type.name == overhaul.maintenance_type.name
for wot in ws.workscope_group.oh_types
)
for ws in eq_workscopes
)
is_natural_inclusion = any((ws.workscope_group and any((wot.oh_type.name == overhaul.maintenance_type.name for wot in ws.workscope_group.oh_types)) for ws in eq_workscopes))
if is_natural_inclusion:
print(f"Equipment {location_tag} is naturally part of OH {overhaul.maintenance_type.name}, skip removal")
print(f'Equipment {location_tag} is naturally part of OH {overhaul.maintenance_type.name}, skip removal')
return False
# --- Step 2: Remove dummy workscope group for this overhaul ---
dummy_name = f"Included Equipment Workscope - {overhaul.maintenance_type.name}"
dummy_group = next(
(wg for wg in equipment.workscope_groups if wg.workscope_group.workscope == dummy_name),
None,
)
dummy_name = f'Included Equipment Workscope - {overhaul.maintenance_type.name}'
dummy_group = next((wg for wg in equipment.workscope_groups if wg.workscope_group.workscope == dummy_name), None)
if dummy_group:
equipment.workscope_groups.remove(dummy_group)
await db_session.commit()
await db_session.refresh(equipment)
# --- Step 3 (optional): Delete StandardScope if no groups left ---
if not equipment.workscope_groups:
await db_session.delete(equipment)
await db_session.commit()
print(f"Equipment {location_tag} completely removed from StandardScope")
print(f'Equipment {location_tag} completely removed from StandardScope')
return True
print(f"No dummy workscope group found for {location_tag} in OH {overhaul.maintenance_type.name}")
print(f'No dummy workscope group found for {location_tag} in OH {overhaul.maintenance_type.name}')
return False
except Exception as e:
print(f"Error removing equipment {location_tag}: {str(e)}")
print(f'Error removing equipment {location_tag}: {str(e)}')
await db_session.rollback()
return False

@ -1,37 +1,24 @@
from decimal import Decimal, getcontext
def get_material_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28
if not total_equipment: # Guard against division by zero
if not total_equipment:
return float(0)
cost = 365539731101 / 10
if scope == "B":
result = Decimal(f"{cost}") / Decimal(str(total_equipment))
if scope == 'B':
result = Decimal(f'{cost}') / Decimal(str(total_equipment))
return float(result)
else:
result = Decimal("8565468127") / Decimal(str(total_equipment))
result = Decimal('8565468127') / Decimal(str(total_equipment))
return float(result)
return float(0)
def get_service_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28
if not total_equipment: # Guard against division by zero
if not total_equipment:
return float(0)
if scope == "B":
result = Decimal("36405830225") / Decimal(str(total_equipment))
if scope == 'B':
result = Decimal('36405830225') / Decimal(str(total_equipment))
return float(result)
else:
result = Decimal("36000000000") / Decimal(str(total_equipment))
result = Decimal('36000000000') / Decimal(str(total_equipment))
return float(result)
return float(0)

@ -1,17 +1,8 @@
from sqlalchemy import Column, String
from src.database.core import Base
from src.models import DefaultMixin
class OverhaulGantt(Base, DefaultMixin):
__tablename__ = "oh_ms_monitoring_spreadsheet"
__tablename__ = 'oh_ms_monitoring_spreadsheet'
spreadsheet_id = Column(String, nullable=True)
spreadsheet_link = Column(String, nullable=True)

@ -1,143 +1,46 @@
import re
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from sqlalchemy import select
from src.auth.service import CurrentUser
from src.auth.service import Admin
from src.csrf_protect import csrf_protect
from src.database.core import DbSession
from src.database.service import CommonParameters
from src.models import StandardResponse
from src.overhaul_gantt.model import OverhaulGantt
from src.overhaul_gantt.schema import OverhaulGanttIn
# from .schema import (OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate)
from .service import get_gantt_performance_chart
router = APIRouter()
@router.get(
"", response_model=StandardResponse[dict]
)
@router.get('', response_model=StandardResponse[dict])
async def get_gantt_performance(db_session: DbSession):
"""Get all scope pagination."""
# return
query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none()
results, gantt_data = await get_gantt_performance_chart(spreadsheet_id=data.spreadsheet_id)
return StandardResponse(data={'chart_data': results, 'gantt_data': gantt_data}, message='Data retrieved successfully')
return StandardResponse(
data={
"chart_data": results,
"gantt_data": gantt_data
},
message="Data retrieved successfully",
)
@router.get(
"/spreadsheet", response_model=StandardResponse[dict]
)
@router.get('/spreadsheet', response_model=StandardResponse[dict])
async def get_gantt_spreadsheet(db_session: DbSession):
"""Get all scope pagination."""
# return
query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none()
result = {
"spreadsheet_id": None,
"spreadsheet_link": None
}
result = {'spreadsheet_id': None, 'spreadsheet_link': None}
if data:
result = {
"spreadsheet_id": data.spreadsheet_id,
"spreadsheet_link": data.spreadsheet_link
}
return StandardResponse(
data=result,
message="Data retrieved successfully",
)
result = {'spreadsheet_id': data.spreadsheet_id, 'spreadsheet_link': data.spreadsheet_link}
return StandardResponse(data=result, message='Data retrieved successfully')
@router.post(
"/spreadsheet", response_model=StandardResponse[dict], dependencies=[Depends(csrf_protect)]
)
async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn):
"""Get all scope pagination."""
# return
match = re.search(r"/d/([a-zA-Z0-9-_]+)", spreadsheet_in.spreadsheet_link)
@router.post('/spreadsheet', response_model=StandardResponse[dict], dependencies=[Depends(csrf_protect)])
async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn, admin: Admin):
match = re.search('/d/([a-zA-Z0-9-_]+)', spreadsheet_in.spreadsheet_link)
if not match:
raise ValueError("Invalid Google Sheets URL")
raise ValueError('Invalid Google Sheets URL')
spreadsheet_id = match.group(1)
query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none()
if data:
data.spreadsheet_link = spreadsheet_in.spreadsheet_link
data.spreadsheet_id = spreadsheet_id
else:
spreadsheet = OverhaulGantt(
spreadsheet_id=spreadsheet_id,
spreadsheet_link=spreadsheet_in.spreadsheet_link
)
spreadsheet = OverhaulGantt(spreadsheet_id=spreadsheet_id, spreadsheet_link=spreadsheet_in.spreadsheet_link)
db_session.add(spreadsheet)
await db_session.commit()
if data:
result = {
"spreadsheet_id": spreadsheet_id
}
return StandardResponse(
data=result,
message="Data retrieved successfully",
)
# @router.post("", response_model=StandardResponse[None])
# async def create_overhaul_equipment_jobs(
# db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
# ):
# await create(
# db_session=db_session,
# overhaul_job_in=overhaul_job_in,
# )
# return StandardResponse(
# data=None,
# message="Data created successfully",
# )
# @router.put("/{overhaul_job_id}", response_model=StandardResponse[None])
# async def update_overhaul_schedule(
# db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate
# ):
# await update(db_session=db_session, overhaul_schedule_id=overhaul_job_id, overhaul_job_in=overhaul_job_in)
# return StandardResponse(
# data=None,
# message="Data updated successfully",
# )
# @router.delete("/{overhaul_job_id}", response_model=StandardResponse[None])
# async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id):
# await delete(db_session=db_session, overhaul_schedule_id=overhaul_job_id)
# return StandardResponse(
# data=None,
# message="Data deleted successfully",
# )
result = {'spreadsheet_id': spreadsheet_id}
return StandardResponse(data=result, message='Data retrieved successfully')

@ -1,48 +1,5 @@
# from datetime import datetime
# from typing import List, Optional
# from uuid import UUID
# from pydantic import Field
# from src.models import DefultBase, Pagination
# from src.overhaul_scope.schema import ScopeRead
# from src.scope_equipment_job.schema import ScopeEquipmentJobRead
# from src.job.schema import ActivityMasterRead
from pydantic import Field
from src.models import DefultBase
class OverhaulGanttIn(DefultBase):
spreadsheet_link: str = Field(...)
# class OverhaulScheduleCreate(OverhaulScheduleBase):
# year: int
# plan_duration: Optional[int] = Field(None)
# planned_outage: Optional[int] = Field(None)
# actual_shutdown: Optional[int] = Field(None)
# start: datetime
# finish: datetime
# remark: Optional[str] = Field(None)
# class OverhaulScheduleUpdate(OverhaulScheduleBase):
# start: datetime
# finish: datetime
# class OverhaulScheduleRead(OverhaulScheduleBase):
# id: UUID
# year: int
# plan_duration: Optional[int]
# planned_outage: Optional[int]
# actual_shutdown: Optional[int]
# start: datetime
# finish: datetime
# remark: Optional[str]
# class OverhaulSchedulePagination(Pagination):
# items: List[OverhaulScheduleRead] = []

@ -1,112 +1,34 @@
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import Delete, Select, func
from sqlalchemy.orm import selectinload
# from .model import OverhaulSchedule
# from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate
from .utils import fetch_all_sections, get_google_creds, get_spreatsheed_service, process_spreadsheet_data
# async def get_all(*, common):
# """Returns all documents."""
# query = Select(OverhaulSchedule).order_by(OverhaulSchedule.start.desc())
# results = await search_filter_sort_paginate(model=query, **common)
# return results
# async def create(
# *, db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
# ):
# schedule = OverhaulSchedule(**overhaul_job_in.model_dump())
# db_session.add(schedule)
# await db_session.commit()
# return schedule
# async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_job_in: OverhaulScheduleUpdate):
# """Updates a document."""
# data = overhaul_job_in.model_dump()
# overhaul_schedule = await db_session.get(OverhaulSchedule, overhaul_schedule_id)
# update_data = overhaul_job_in.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(overhaul_schedule, field, update_data[field])
# await db_session.commit()
# return overhaul_schedule
# async def delete(*, db_session: DbSession, overhaul_schedule_id: str):
# """Deletes a document."""
# query = Delete(OverhaulSchedule).where(OverhaulSchedule.id == overhaul_schedule_id)
# await db_session.execute(query)
# await db_session.commit()
async def get_gantt_performance_chart(*, spreadsheet_id = "1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk"):
async def get_gantt_performance_chart(*, spreadsheet_id='1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk'):
creds = get_google_creds()
RANGE_NAME = "'SUMMARY'!K34:AZ38" # Or just "2024 schedule"
GANTT_DATA_NAME = "ACTUAL PROGRESS"
RANGE_NAME = "'SUMMARY'!K34:AZ38"
GANTT_DATA_NAME = 'ACTUAL PROGRESS'
try:
service = get_spreatsheed_service(creds)
sheet = service.spreadsheets()
response = sheet.values().get(
spreadsheetId=spreadsheet_id,
range=RANGE_NAME
).execute()
values = response.get("values", [])
response = sheet.values().get(spreadsheetId=spreadsheet_id, range=RANGE_NAME).execute()
values = response.get('values', [])
if len(values) < 4:
raise Exception("Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).")
# Extract rows
raise Exception('Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).')
day_row = values[0][1:]
date_row = values[1][1:]
plan_row = values[3][1:]
actual_row = values[4][1:]
total_days = len(day_row)
# PAD rows so lengths match day count
date_row += [""] * (total_days - len(date_row))
plan_row += [""] * (total_days - len(plan_row))
actual_row += [""] * (total_days - len(actual_row))
date_row += [''] * (total_days - len(date_row))
plan_row += [''] * (total_days - len(plan_row))
actual_row += [''] * (total_days - len(actual_row))
results = []
for i in range(total_days):
day = day_row[i]
date = date_row[i]
plan = plan_row[i]
actual = actual_row[i] if actual_row[i] else "0%" # <-- FIX HERE
results.append({
"day": day,
"date": date,
"plan": plan,
"actual": actual
})
actual = actual_row[i] if actual_row[i] else '0%'
results.append({'day': day, 'date': date, 'plan': plan, 'actual': actual})
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
processed_data = process_spreadsheet_data(results)
gantt_data = fetch_all_sections(service=service, spreadsheet_id=spreadsheet_id, sheet_name=GANTT_DATA_NAME)
return processed_data, gantt_data
return (processed_data, gantt_data)

@ -1,22 +1,12 @@
import urllib
from google.oauth2.service_account import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
SCOPES = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly']
def get_spreatsheed_service(credentials):
return build("sheets", "v4", credentials=credentials, cache_discovery=False)
return build('sheets', 'v4', credentials=credentials, cache_discovery=False)
def get_google_creds():
creds = None
creds = Credentials.from_service_account_file("credentials.json", scopes=SCOPES)
return creds
return Credentials.from_service_account_file('credentials.json', scopes=SCOPES)
def process_spreadsheet_data(rows):
processed_data = []
@ -24,107 +14,61 @@ def process_spreadsheet_data(rows):
processed_row = convert_spreadsheet_data(row)
processed_data.append(processed_row) if processed_row else None
return processed_data
from datetime import datetime
from datetime import datetime
def convert_spreadsheet_data(data, default_year=None):
"""
Convert spreadsheet row into structured data.
Expected keys: day, date, plan, actual
"""
# Skip header or invalid rows
if not data.get("day") or not data["day"].isdigit():
if not data.get('day') or not data['day'].isdigit():
return None
result = {}
# Convert day
result["day"] = int(data["day"])
# Determine default year
result['day'] = int(data['day'])
if default_year is None:
default_year = datetime.now().year
date_str = data.get("date", "").strip()
# ---------- DATE HANDLING ----------
# Accept formats like: "Nov 20", "Dec 3", "Jan 1"
date_str = data.get('date', '').strip()
parsed_date = None
if date_str:
try:
parsed_date = datetime.strptime(f"{date_str} {default_year}", "%b %d %Y")
parsed_date = datetime.strptime(f'{date_str} {default_year}', '%b %d %Y')
except ValueError:
try:
parsed_date = datetime.strptime(f"{date_str} {default_year}", "%B %d %Y")
parsed_date = datetime.strptime(f'{date_str} {default_year}', '%B %d %Y')
except:
parsed_date = None
# YEAR ROLLOVER (Dec → Jan next year)
if parsed_date and parsed_date.month == 1 and "Dec" in data.get("date", ""):
if parsed_date and parsed_date.month == 1 and ('Dec' in data.get('date', '')):
parsed_date = parsed_date.replace(year=default_year + 1)
result['date'] = parsed_date
result["date"] = parsed_date
# ---------- PERCENT HANDLING ----------
def parse_percent(value):
if not value:
return 0.0
v = value.strip().replace(",", ".").replace("%", "")
v = value.strip().replace(',', '.').replace('%', '')
try:
return float(v) / 100.0
except:
return 0.0
result["plan"] = parse_percent(data.get("plan", "0"))
result["actual"] = parse_percent(data.get("actual", "0"))
# Gap calculation
result["gap"] = result["actual"] - result["plan"]
result['plan'] = parse_percent(data.get('plan', '0'))
result['actual'] = parse_percent(data.get('actual', '0'))
result['gap'] = result['actual'] - result['plan']
return result
def fetch_all_sections(service, spreadsheet_id, sheet_name):
# Fetch a wide range including columns AL
result = service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range=f"{sheet_name}!A5:M5000"
).execute()
values = result.get("values", [])
result = service.spreadsheets().values().get(spreadsheetId=spreadsheet_id, range=f'{sheet_name}!A5:M5000').execute()
values = result.get('values', [])
if not values:
raise ValueError("No data found in sheet")
raise ValueError('No data found in sheet')
data = []
current_section = None
current_subsystem = None
for row in values:
# Pad missing columns to avoid index errors
row += [""] * (13 - len(row))
row += [''] * (13 - len(row))
colA, colB, colC, colD, colE, colF, colG, colH, colI, colJ, colK, colL, colM = row
# Detect a SECTION — bold blue rows in Column C
if colC and not colD and not colE:
if colC and (not colD) and (not colE):
current_section = colC.strip()
current_subsystem = None
continue
# Detect a SUBSYSTEM — indented header in Column D
if colD and not colE:
if colD and (not colE):
current_subsystem = colD.strip()
continue
# Detect a TASK — Column E populated
if colE:
task = colE.strip()
pic = colF.strip()
@ -134,64 +78,21 @@ def fetch_all_sections(service, spreadsheet_id, sheet_name):
plan = colK.strip()
actual = colL.strip()
gap = colM.strip()
data.append({
"system": current_section,
"subsystem": current_subsystem,
"task": task,
"PIC": pic,
"start_date": start_date,
"end_date": finish_date,
"duration": int(duration),
"plan": plan,
"actual": actual,
"gap": gap
})
data.append({'system': current_section, 'subsystem': current_subsystem, 'task': task, 'PIC': pic, 'start_date': start_date, 'end_date': finish_date, 'duration': int(duration), 'plan': plan, 'actual': actual, 'gap': gap})
return data
def indo_formatted_date(date_str, base_year=2025):
"""
Convert short date like 'Nov 20', '30-Dec', 'Jan 1'
into: 'Rabu, November 20, 2025'
If month is January, year becomes 2026.
"""
# Month mappings
eng_to_indo_month = {
"Jan": "Januari", "Feb": "Februari", "Mar": "Maret", "Apr": "April",
"May": "Mei", "Jun": "Juni", "Jul": "Juli", "Aug": "Agustus",
"Sep": "September", "Oct": "Oktober", "Nov": "November", "Dec": "Desember"
}
indo_days = {
0: "Senin",
1: "Selasa",
2: "Rabu",
3: "Kamis",
4: "Jumat",
5: "Sabtu",
6: "Minggu"
}
# Normalize formats ("30-Dec" → "Dec 30")
if "-" in date_str:
d, m = date_str.split("-")
date_str = f"{m} {d}"
# Parse using English abbreviation
eng_to_indo_month = {'Jan': 'Januari', 'Feb': 'Februari', 'Mar': 'Maret', 'Apr': 'April', 'May': 'Mei', 'Jun': 'Juni', 'Jul': 'Juli', 'Aug': 'Agustus', 'Sep': 'September', 'Oct': 'Oktober', 'Nov': 'November', 'Dec': 'Desember'}
indo_days = {0: 'Senin', 1: 'Selasa', 2: 'Rabu', 3: 'Kamis', 4: 'Jumat', 5: 'Sabtu', 6: 'Minggu'}
if '-' in date_str:
d, m = date_str.split('-')
date_str = f'{m} {d}'
try:
dt = datetime.strptime(f"{date_str} {base_year}", "%b %d %Y")
dt = datetime.strptime(f'{date_str} {base_year}', '%b %d %Y')
except:
return None
# Handle year rollover (Jan -> next year)
if dt.month == 1:
dt = dt.replace(year=base_year + 1)
# Convert to Indonesian components
day_name = indo_days[dt.weekday()]
month_name = eng_to_indo_month[dt.strftime("%b")]
return f"{day_name}, {month_name} {dt.day}, {dt.year}"
month_name = eng_to_indo_month[dt.strftime('%b')]
return f'{day_name}, {month_name} {dt.day}, {dt.year}'

@ -1,43 +1 @@
from sqlalchemy import (UUID, Column, DateTime, Float, ForeignKey, Integer,
String)
from sqlalchemy.orm import relationship
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
# class EquipmentWorkscopeGroup(Base, DefaultMixin):
# __tablename__ = "oh_tr_equipment_workscope_group"
# # overhaul_activity_id = Column(
# # UUID(as_uuid=True), ForeignKey("oh_tr_overhaul_activity.id"), nullable=False
# # )
# # scope_equipment_job_id = Column(
# # UUID(as_uuid=True),
# # ForeignKey("oh_ms_scope_equipment_job.id", ondelete="cascade"),
# # nullable=False,
# # )
# # notes = Column(String, nullable=True)
# # status = Column(String, nullable=True, default="pending")
# workscope_group_id = Column(
# UUID(as_uuid=True), ForeignKey("oh_ms_workscope_group.id"), nullable=False
# )
# location_tag = Column(String, nullable=False)
# equipment = relationship(
# "StandardScope",
# lazy="selectin",
# primaryjoin="and_(OverhaulJob.location_tag == foreign(StandardScope.location_tag))",
# uselist=False, # Add this if it's a one-to-one relationship
# )
# workscope_group = relationship("MasterActivity", lazy="selectin", back_populates="equipment_workscope_groups")
# # scope_equipment_job = relationship(
# # "ScopeEquipmentJob", lazy="raise", back_populates="overhaul_jobs"
# # )
# # overhaul_activity = relationship("OverhaulActivity", lazy="raise", back_populates="overhaul_jobs")

@ -1,91 +1,23 @@
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status, Query
from src.auth.service import CurrentUser
from typing import Optional
from fastapi import APIRouter, Query
from src.database.core import DbSession
from src.database.service import CommonParameters
from src.models import StandardResponse
from .schema import (OverhaulJobBase, OverhaulJobCreate, OverhaulJobPagination,
OverhaulJobRead)
from .schema import OverhaulJobCreate, OverhaulJobPagination
from .service import create, delete, get_all
router = APIRouter()
@router.get(
"/{location_tag}", response_model=StandardResponse[OverhaulJobPagination]
)
@router.get('/{location_tag}', response_model=StandardResponse[OverhaulJobPagination])
async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str]=Query(None)):
"""Get all scope pagination."""
# return
results = await get_all(common=common, location_tag=location_tag, scope=scope)
return StandardResponse(data=results, message='Data retrieved successfully')
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.post('/{overhaul_equipment_id}', response_model=StandardResponse[None])
async def create_overhaul_equipment_jobs(db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate):
await create(db_session=db_session, overhaul_equipment_id=overhaul_equipment_id, overhaul_job_in=overhaul_job_in)
return StandardResponse(data=None, message='Data created successfully')
@router.post("/{overhaul_equipment_id}", response_model=StandardResponse[None])
async def create_overhaul_equipment_jobs(
db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate
):
"""Get all scope activity pagination."""
# return
await create(
db_session=db_session,
overhaul_equipment_id=overhaul_equipment_id,
overhaul_job_in=overhaul_job_in,
)
return StandardResponse(
data=None,
message="Data created successfully",
)
@router.post("/delete/{overhaul_job_id}", response_model=StandardResponse[None])
@router.post('/delete/{overhaul_job_id}', response_model=StandardResponse[None])
async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id):
await delete(db_session=db_session, overhaul_job_id=overhaul_job_id)
return StandardResponse(
data=None,
message="Data deleted successfully",
)
# @router.post("", response_model=StandardResponse[List[str]])
# async def create_scope(db_session: DbSession, scope_in: OverhaulJobCreate):
# overhaul_job = await create(db_session=db_session, scope_in=scope_in)
# return StandardResponse(data=overhaul_job, message="Data created successfully")
# @router.put("/{scope_id}", response_model=StandardResponse[ScopeRead])
# async def update_scope(db_session: DbSession, scope_id: str, scope_in: ScopeUpdate, current_user: CurrentUser):
# scope = await get(db_session=db_session, scope_id=scope_id)
# if not scope:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(data=await update(db_session=db_session, scope=scope, scope_in=scope_in), message="Data updated successfully")
# @router.delete("/{scope_id}", response_model=StandardResponse[ScopeRead])
# async def delete_scope(db_session: DbSession, scope_id: str):
# scope = await get(db_session=db_session, scope_id=scope_id)
# if not scope:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail=[{"msg": "A data with this id does not exist."}],
# )
# await delete(db_session=db_session, scope_id=scope_id)
# return StandardResponse(message="Data deleted successfully", data=scope)
return StandardResponse(data=None, message='Data deleted successfully')

@ -1,26 +1,18 @@
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from pydantic import Field
from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead
from src.scope_equipment_job.schema import ScopeEquipmentJobRead
from src.job.schema import ActivityMasterRead
class OverhaulJobBase(DefultBase):
pass
class OverhaulJobCreate(OverhaulJobBase):
job_ids: Optional[List[UUID]] = []
class OverhaulJobUpdate(OverhaulJobBase):
pass
class OverhaulActivity(DefultBase):
id: UUID
overhaul_scope_id: UUID
@ -34,7 +26,5 @@ class OverhaulJobRead(OverhaulJobBase):
scope_equipment_job: ScopeEquipment
overhaul_activity: OverhaulActivity
class OverhaulJobPagination(Pagination):
items: List[OverhaulJobRead] = []

@ -1,15 +1,8 @@
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import Delete, Select, func
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from sqlalchemy import Select
from src.database.core import DbSession
from src.database.service import search_filter_sort_paginate
# from src.scope_equipment_job.model import ScopeEquipmentJob
# from src.overhaul_activity.model import OverhaulActivity
from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.model import MaintenanceType
@ -17,107 +10,32 @@ from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from .schema import OverhaulJobCreate
from src.soft_delete import apply_not_deleted_filter, soft_delete_record
async def get_all(*, common, location_tag: str, scope: Optional[str]=None):
"""Returns all documents."""
query = (
Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag)
)
query = Select(EquipmentWorkscopeGroup).where(EquipmentWorkscopeGroup.location_tag == location_tag)
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup)
if scope:
query = (
query
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.filter(MaintenanceType.name == scope)
)
results = await search_filter_sort_paginate(model=query, **common)
return results
query = query.join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).filter(MaintenanceType.name == scope)
return await search_filter_sort_paginate(model=query, **common)
async def create(
*, db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate
):
async def create(*, db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate):
overhaul_jobs = []
if not overhaul_equipment_id:
raise ValueError("assetnum parameter is required")
equipment_stmt = Select(OverhaulJob).where(
OverhaulJob.overhaul_activity_id == overhaul_equipment_id
)
equipment = await db_session.scalar(equipment_stmt)
raise ValueError('assetnum parameter is required')
equipment_stmt = Select(OverhaulJob).where(OverhaulJob.overhaul_activity_id == overhaul_equipment_id)
await db_session.scalar(equipment_stmt)
for job_id in overhaul_job_in.job_ids:
overhaul_equipment_job = OverhaulJob(
overhaul_activity_id=overhaul_equipment_id, scope_equipment_job_id=job_id
)
overhaul_equipment_job = OverhaulJob(overhaul_activity_id=overhaul_equipment_id, scope_equipment_job_id=job_id)
overhaul_jobs.append(overhaul_equipment_job)
db_session.add_all(overhaul_jobs)
await db_session.commit()
return overhaul_job_in.job_ids
async def delete(
*,
db_session: DbSession,
overhaul_job_id: str,
) -> bool:
"""
Deletes a scope job and returns success status.
Args:
db_session: Database session
scope_job_id: ID of the scope job to delete
user_id: ID of user performing the deletion
Returns:
bool: True if deletion was successful, False otherwise
Raises:
NotFoundException: If scope job doesn't exist
AuthorizationError: If user lacks delete permission
"""
async def delete(*, db_session: DbSession, overhaul_job_id: str) -> bool:
try:
# Soft-delete the record
deleted = await soft_delete_record(db_session, EquipmentWorkscopeGroup, overhaul_job_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
return True
except Exception as e:
except Exception:
await db_session.rollback()
raise
# async def update(*, db_session: DbSession, scope: OverhaulScope, scope_in: ScopeUpdate):
# """Updates a document."""
# data = scope_in.model_dump()
# update_data = scope_in.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(scope, field, update_data[field])
# await db_session.commit()
# return scope
# async def delete(*, db_session: DbSession, scope_id: str):
# """Deletes a document."""
# query = Delete(OverhaulScope).where(OverhaulScope.id == scope_id)
# await db_session.execute(query)
# await db_session.commit()

@ -1,18 +1,13 @@
from sqlalchemy import (UUID, Column, DateTime, Float, ForeignKey, Integer,
String)
from sqlalchemy.orm import relationship
from sqlalchemy import Column, DateTime, Integer, String
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.models import DefaultMixin
class OverhaulSchedule(Base, DefaultMixin):
__tablename__ = "rp_oh_schedule"
__tablename__ = 'rp_oh_schedule'
year = Column(Integer, nullable=False)
plan_duration = Column(Integer, nullable=True)
planned_outage = Column(Integer, nullable=True)
actual_shutdown = Column(Integer, nullable=True)
start = Column(DateTime(timezone=True)) # This will be TIMESTAMP WITH TIME ZONE
start = Column(DateTime(timezone=True))
finish = Column(DateTime(timezone=True))
remark = Column(String, nullable=True)

@ -1,63 +1,27 @@
from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
from src.auth.service import CurrentUser
from fastapi import APIRouter
from src.database.core import DbSession
from src.database.service import CommonParameters
from src.models import StandardResponse
from .schema import (OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate)
from .schema import OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate
from .service import create, get_all, delete, update
router = APIRouter()
@router.get(
"", response_model=StandardResponse[OverhaulSchedulePagination]
)
@router.get('', response_model=StandardResponse[OverhaulSchedulePagination])
async def get_schedules(common: CommonParameters):
"""Get all scope pagination."""
# return
results = await get_all(common=common)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.post('', response_model=StandardResponse[None])
async def create_overhaul_equipment_jobs(db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate):
await create(db_session=db_session, overhaul_job_in=overhaul_job_in)
return StandardResponse(data=None, message='Data created successfully')
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.post("", response_model=StandardResponse[None])
async def create_overhaul_equipment_jobs(
db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
):
await create(
db_session=db_session,
overhaul_job_in=overhaul_job_in,
)
return StandardResponse(
data=None,
message="Data created successfully",
)
@router.post("/update/{overhaul_job_id}", response_model=StandardResponse[None])
async def update_overhaul_schedule(
db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate
):
@router.post('/update/{overhaul_job_id}', response_model=StandardResponse[None])
async def update_overhaul_schedule(db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate):
await update(db_session=db_session, overhaul_schedule_id=overhaul_job_id, overhaul_job_in=overhaul_job_in)
return StandardResponse(data=None, message='Data updated successfully')
return StandardResponse(
data=None,
message="Data updated successfully",
)
@router.post("/delete/{overhaul_job_id}", response_model=StandardResponse[None])
@router.post('/delete/{overhaul_job_id}', response_model=StandardResponse[None])
async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id):
await delete(db_session=db_session, overhaul_schedule_id=overhaul_job_id)
return StandardResponse(
data=None,
message="Data deleted successfully",
)
return StandardResponse(data=None, message='Data deleted successfully')

@ -1,18 +1,12 @@
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from pydantic import Field
from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead
from src.scope_equipment_job.schema import ScopeEquipmentJobRead
from src.job.schema import ActivityMasterRead
class OverhaulScheduleBase(DefultBase):
pass
class OverhaulScheduleCreate(OverhaulScheduleBase):
year: int
plan_duration: Optional[int] = Field(None)
@ -22,12 +16,10 @@ class OverhaulScheduleCreate(OverhaulScheduleBase):
finish: datetime
remark: Optional[str] = Field(None)
class OverhaulScheduleUpdate(OverhaulScheduleBase):
start: datetime
finish: datetime
class OverhaulScheduleRead(OverhaulScheduleBase):
id: UUID
year: int
@ -38,7 +30,5 @@ class OverhaulScheduleRead(OverhaulScheduleBase):
finish: datetime
remark: Optional[str]
class OverhaulSchedulePagination(Pagination):
items: List[OverhaulScheduleRead] = []

@ -1,60 +1,33 @@
from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import Delete, Select, func
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from sqlalchemy import Select
from src.database.core import DbSession
from src.database.service import search_filter_sort_paginate
from src.utils import update_model
from src.scope_equipment_job.model import ScopeEquipmentJob
from src.overhaul_activity.model import OverhaulActivity
from src.soft_delete import apply_not_deleted_filter, soft_delete_record
from .model import OverhaulSchedule
from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate
async def get_all(*, common):
"""Returns all documents."""
query = Select(OverhaulSchedule).order_by(OverhaulSchedule.start.desc())
query = apply_not_deleted_filter(query, OverhaulSchedule)
return await search_filter_sort_paginate(model=query, **common)
results = await search_filter_sort_paginate(model=query, **common)
return results
async def create(
*, db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
):
async def create(*, db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate):
schedule = OverhaulSchedule(**overhaul_job_in.model_dump())
db_session.add(schedule)
await db_session.commit()
return schedule
async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_job_in: OverhaulScheduleUpdate):
"""Updates a document."""
data = overhaul_job_in.model_dump()
overhaul_job_in.model_dump()
query = Select(OverhaulSchedule).where(OverhaulSchedule.id == overhaul_schedule_id)
query = apply_not_deleted_filter(query, OverhaulSchedule)
query = query.with_for_update()
result = await db_session.execute(query)
overhaul_schedule = result.scalars().one_or_none()
update_data = overhaul_job_in.model_dump(exclude_defaults=True)
update_model(overhaul_schedule, update_data)
await db_session.commit()
return overhaul_schedule
async def delete(*, db_session: DbSession, overhaul_schedule_id: str):
"""Soft-deletes a document."""
await soft_delete_record(db_session=db_session, model=OverhaulSchedule, record_id=overhaul_schedule_id)

@ -1,48 +1,29 @@
from sqlalchemy import JSON
from sqlalchemy import Column, DateTime, Integer, String, ForeignKey, UUID
from sqlalchemy.orm import relationship
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.models import DefaultMixin
from src.auth.access_control import Allow, RolePrincipal, OHPermission
class OverhaulScope(Base, DefaultMixin):
__tablename__ = "oh_ms_overhaul"
start_date = Column(DateTime(timezone=True), nullable=False) # Made non-nullable to match model
end_date = Column(DateTime(timezone=True), nullable=True) # Already nullable
__tablename__ = 'oh_ms_overhaul'
start_date = Column(DateTime(timezone=True), nullable=False)
end_date = Column(DateTime(timezone=True), nullable=True)
duration_oh = Column(Integer, nullable=True)
crew_number = Column(Integer, nullable=True, default=1)
status = Column(String, nullable=False, default="Upcoming")
maintenance_type_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_maintenance_type.id"), nullable=False)
status = Column(String, nullable=False, default='Upcoming')
maintenance_type_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_maintenance_type.id'), nullable=False)
wo_parent = Column(JSON, nullable=True)
@classmethod
def __acl__(self):
basic_permissions = [OHPermission.READ]
engineer_permissions = [
OHPermission.READ,
OHPermission.CREATE,
OHPermission.EDIT,
OHPermission.DELETE,
]
engineer_permissions = [OHPermission.READ, OHPermission.CREATE, OHPermission.EDIT, OHPermission.DELETE]
all_permissions = list(OHPermission)
return [
(Allow, RolePrincipal("Management"), basic_permissions),
(Allow, RolePrincipal("Engineer"), engineer_permissions),
(Allow, RolePrincipal("Admin"), all_permissions),
(Allow, RolePrincipal("Application Administrator"), all_permissions),
]
maintenance_type = relationship("MaintenanceType", lazy="selectin", backref="overhaul_scopes")
# activity_equipments = relationship("OverhaulActivity", lazy="selectin")
return [(Allow, RolePrincipal('Management'), basic_permissions), (Allow, RolePrincipal('Engineer'), engineer_permissions), (Allow, RolePrincipal('Admin'), all_permissions), (Allow, RolePrincipal('Application Administrator'), all_permissions)]
maintenance_type = relationship('MaintenanceType', lazy='selectin', backref='overhaul_scopes')
class MaintenanceType(Base, DefaultMixin):
__tablename__ = "oh_ms_maintenance_type"
code = Column(String, nullable=False, default="OH")
__tablename__ = 'oh_ms_maintenance_type'
code = Column(String, nullable=False, default='OH')
name = Column(String, nullable=False)

@ -1,82 +1,47 @@
from typing import List
from typing import Optional
from fastapi import APIRouter, HTTPException, status
from src.auth.service import CurrentUser
from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate
from src.database.service import CommonParameters
from src.models import StandardResponse
from .model import OverhaulScope
from .schema import ScopeCreate, ScopePagination, ScopeRead, ScopeUpdate
from .service import create, delete, get, get_all, update, get_all_oh_with_history_service
from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES
from src.csrf_protect import csrf_protect
from fastapi import Depends
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("", response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get('', response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_scopes(common: CommonParameters, scope_name: Optional[str]=None):
"""Get all scope pagination."""
# return
results = await get_all(common=common, scope_name=scope_name)
return StandardResponse(data=results, message='Data retrieved successfully')
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.get("/history", response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get('/history', response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_history(db_session: DbSession):
return StandardResponse(data=await get_all_oh_with_history_service(db_session=db_session), message="Data retrieved successfully")
return StandardResponse(data=await get_all_oh_with_history_service(db_session=db_session), message='Data retrieved successfully')
@router.get("/{overhaul_session_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get('/{overhaul_session_id}', response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_scope(db_session: DbSession, overhaul_session_id: str):
scope = await get(db_session=db_session, overhaul_session_id=overhaul_session_id)
if not scope:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return StandardResponse(data=scope, message="Data retrieved successfully")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
return StandardResponse(data=scope, message='Data retrieved successfully')
@router.post("", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))])
@router.post('', response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))])
async def create_scope(db_session: DbSession, scope_in: ScopeCreate):
scope = await create(db_session=db_session, scope_in=scope_in)
return StandardResponse(data=scope, message='Data created successfully')
return StandardResponse(data=scope, message="Data created successfully")
@router.post('/update/{scope_id}', response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope))])
async def update_scope(db_session: DbSession, scope_id: str, scope_in: ScopeUpdate, current_user: CurrentUser):
return StandardResponse(data=await update(db_session=db_session, scope_id=scope_id, scope_in=scope_in), message='Data updated successfully')
@router.post("/update/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope))])
async def update_scope(
db_session: DbSession,
scope_id: str,
scope_in: ScopeUpdate,
current_user: CurrentUser,
):
return StandardResponse(
data=await update(db_session=db_session, scope_id=scope_id, scope_in=scope_in),
message="Data updated successfully",
)
@router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope)), Depends(csrf_protect)])
@router.post('/delete/{scope_id}', response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope)), Depends(csrf_protect)])
async def delete_scope(db_session: DbSession, scope_id: str):
scope = await get(db_session=db_session, overhaul_session_id=scope_id)
if not scope:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=[{"msg": "A data with this id does not exist."}],
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=[{'msg': 'A data with this id does not exist.'}])
await delete(db_session=db_session, scope_id=scope_id)
return StandardResponse(message="Data deleted successfully", data=scope)
return StandardResponse(message='Data deleted successfully', data=scope)

@ -1,17 +1,13 @@
from datetime import datetime
from typing import List, Optional
from uuid import UUID
from pydantic import Field
from src.models import DefultBase, Pagination
class ScopeBase(DefultBase):
duration_oh: Optional[int] = Field(720, title="Duration OH")
crew_number: Optional[int] = Field(10, title="Crew")
status: Optional[str] = Field("Upcoming")
duration_oh: Optional[int] = Field(720, title='Duration OH', ge=0, le=1000000)
crew_number: Optional[int] = Field(10, title='Crew', ge=0, le=1000000)
status: Optional[str] = Field('Upcoming')
class MaintenanceType(DefultBase):
name: str
@ -20,17 +16,14 @@ class ScopeCreate(ScopeBase):
start_date: datetime
end_date: Optional[datetime] = Field(None)
class ScopeUpdate(ScopeBase):
pass
class ScopeRead(ScopeBase):
id: UUID
start_date: datetime
end_date: Optional[datetime]
maintenance_type: MaintenanceType
class ScopePagination(Pagination):
items: List[ScopeRead] = []

@ -1,9 +1,6 @@
from typing import Optional
from sqlalchemy import Delete, Select, func
from sqlalchemy import Select
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from src.database.core import DbSession
from src.database.service import search_filter_sort_paginate
from src.overhaul_activity.model import OverhaulActivity
@ -12,7 +9,6 @@ from src.standard_scope.model import MasterEquipment, StandardScope, EquipmentOH
from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from .model import OverhaulScope, MaintenanceType
from .schema import ScopeCreate, ScopeUpdate
from .utils import get_material_cost, get_service_cost
@ -20,231 +16,111 @@ from datetime import datetime
from uuid import UUID
from src.soft_delete import apply_not_deleted_filter, soft_delete_record
async def get(
*, db_session: DbSession, overhaul_session_id: UUID
) -> Optional[OverhaulScope]:
"""Returns a document based on the given document id."""
async def get(*, db_session: DbSession, overhaul_session_id: UUID) -> Optional[OverhaulScope]:
query = Select(OverhaulScope).filter(OverhaulScope.id == overhaul_session_id).options(selectinload(OverhaulScope.maintenance_type))
query = apply_not_deleted_filter(query, OverhaulScope)
result = await db_session.execute(query)
return result.scalars().one_or_none()
async def get_prev_oh(*, db_session: DbSession, overhaul_session: OverhaulScope) -> Optional[OverhaulScope]:
"""Returns a document based on the given document id."""
result = Select(OverhaulScope).where(OverhaulScope.end_date < overhaul_session.end_date).order_by(OverhaulScope.end_date.desc()).limit(1)
result = apply_not_deleted_filter(result, OverhaulScope)
result = await db_session.execute(result)
return result.scalars().one_or_none()
async def get_all(*, common, scope_name: Optional[str]=None):
"""Returns all documents."""
query = Select(OverhaulScope)
query = apply_not_deleted_filter(query, OverhaulScope)
if scope_name:
query = query.filter(OverhaulScope.maintenance_type.name == scope_name)
results = await search_filter_sort_paginate(model=query, **common)
return results
return await search_filter_sort_paginate(model=query, **common)
async def create(*, db_session: DbSession, scope_in: ScopeCreate):
# Ensure dates are datetime objects
if isinstance(scope_in.start_date, str):
try:
start_date = datetime.fromisoformat(scope_in.start_date.replace('Z', '+00:00'))
except ValueError:
start_date = datetime.strptime(scope_in.start_date, "%Y-%m-%d %H:%M:%S")
start_date = datetime.strptime(scope_in.start_date, '%Y-%m-%d %H:%M:%S')
else:
start_date = scope_in.start_date
# Handle end_date (which could be None)
end_date = None
if scope_in.end_date:
if isinstance(scope_in.end_date, str):
try:
end_date = datetime.fromisoformat(scope_in.end_date.replace('Z', '+00:00'))
except ValueError:
end_date = datetime.strptime(scope_in.end_date, "%Y-%m-%d %H:%M:%S")
end_date = datetime.strptime(scope_in.end_date, '%Y-%m-%d %H:%M:%S')
else:
end_date = scope_in.end_date
# Calculate duration in days if both dates are available
duration_days = None
if start_date and end_date:
duration_days = (end_date - start_date).days
# Prevent TOCTOU for maintenance type creation
maintenance_type = Select(MaintenanceType).where(MaintenanceType.name == scope_in.type).with_for_update()
maintenance_type_exec = await db_session.execute(maintenance_type)
maintenance_type = maintenance_type_exec.scalars().one_or_none()
if maintenance_type is None:
try:
# Need to use nested transaction for safe concurrent creation
async with db_session.begin_nested():
maintenance_type = MaintenanceType(name=scope_in.type)
db_session.add(maintenance_type)
await db_session.flush()
except BaseException:
# Another concurrent request already created it
maintenance_type_exec = await db_session.execute(Select(MaintenanceType).where(MaintenanceType.name == scope_in.type).with_for_update())
maintenance_type = maintenance_type_exec.scalars().one_or_none()
# Create the OverhaulScope object with all hardcoded values
overhaul_session = OverhaulScope(
start_date=scope_in.start_date,
end_date=scope_in.end_date,
maintenance_type=maintenance_type,
duration_oh=duration_days,
crew_number=scope_in.crew_number, # Hardcoded crew number
status=scope_in.status # Hardcoded status
)
# raise Exception(overhaul_session.start_date)
overhaul_session = OverhaulScope(start_date=scope_in.start_date, end_date=scope_in.end_date, maintenance_type=maintenance_type, duration_oh=duration_days, crew_number=scope_in.crew_number, status=scope_in.status)
db_session.add(overhaul_session)
# Need to flush to get the id
await db_session.flush()
scope_name = scope_in.type
# Fix the function call - parameters were in wrong order
equipments = await get_by_scope_name(db_session=db_session, scope_name=scope_name)
material_cost = get_material_cost(
scope=overhaul_session.type, total_equipment=len(equipments)
)
service_cost = get_service_cost(
scope=overhaul_session.type, total_equipment=len(equipments)
)
scope_equipments = [
OverhaulActivity(
assetnum=equipment.assetnum,
overhaul_scope_id=overhaul_session.id,
material_cost=material_cost,
service_cost=service_cost,
)
for equipment in equipments
]
if scope_equipments: # Only add if there are items
material_cost = get_material_cost(scope=overhaul_session.type, total_equipment=len(equipments))
service_cost = get_service_cost(scope=overhaul_session.type, total_equipment=len(equipments))
scope_equipments = [OverhaulActivity(assetnum=equipment.assetnum, overhaul_scope_id=overhaul_session.id, material_cost=material_cost, service_cost=service_cost) for equipment in equipments]
if scope_equipments:
db_session.add_all(scope_equipments)
await db_session.commit()
return overhaul_session
async def update(*, db_session: DbSession, scope_id: str, scope_in: ScopeUpdate):
"""Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update."""
query = (
Select(OverhaulScope)
.filter(OverhaulScope.id == scope_id)
.options(selectinload(OverhaulScope.maintenance_type))
.with_for_update()
)
query = Select(OverhaulScope).filter(OverhaulScope.id == scope_id).options(selectinload(OverhaulScope.maintenance_type)).with_for_update()
query = apply_not_deleted_filter(query, OverhaulScope)
result = await db_session.execute(query)
scope = result.scalars().one_or_none()
if not scope:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.')
await db_session.refresh(scope)
update_data = scope_in.model_dump(exclude_defaults=True)
update_model(scope, update_data)
await db_session.commit()
return scope
async def delete(*, db_session: DbSession, scope_id: str):
"""Soft-deletes a document."""
await soft_delete_record(db_session=db_session, model=OverhaulScope, record_id=scope_id)
async def get_overview_overhaul(*, db_session: DbSession):
current_date = time_now().date()
# 1. Check for ongoing overhaul
ongoing_query = (
Select(OverhaulScope)
.where(
OverhaulScope.start_date <= current_date,
OverhaulScope.end_date >= current_date,
)
)
ongoing_query = Select(OverhaulScope).where(OverhaulScope.start_date <= current_date, OverhaulScope.end_date >= current_date)
ongoing_query = apply_not_deleted_filter(ongoing_query, OverhaulScope)
ongoing_result = await db_session.execute(ongoing_query.options(selectinload(OverhaulScope.maintenance_type)))
ongoing_overhaul = ongoing_result.scalar_one_or_none()
# 2. If no ongoing overhaul, get the closest scheduled overhaul
if ongoing_overhaul is None:
# Get the closest future overhaul (next scheduled)
next_overhaul_query = (
Select(OverhaulScope)
.where(OverhaulScope.start_date > current_date)
.order_by(OverhaulScope.start_date.asc())
.limit(1)
)
next_overhaul_query = Select(OverhaulScope).where(OverhaulScope.start_date > current_date).order_by(OverhaulScope.start_date.asc()).limit(1)
next_overhaul_query = apply_not_deleted_filter(next_overhaul_query, OverhaulScope)
next_result = await db_session.execute(next_overhaul_query.options(selectinload(OverhaulScope.maintenance_type)))
next_overhaul = next_result.scalar_one_or_none()
if next_overhaul:
print(f"Next scheduled overhaul starts on: {next_overhaul.start_date}")
print(f'Next scheduled overhaul starts on: {next_overhaul.start_date}')
selected_overhaul = next_overhaul
else:
print("No upcoming overhauls scheduled")
print('No upcoming overhauls scheduled')
selected_overhaul = None
else:
print(f"Ongoing overhaul found: {ongoing_overhaul.start_date} to {ongoing_overhaul.end_date}")
print(f'Ongoing overhaul found: {ongoing_overhaul.start_date} to {ongoing_overhaul.end_date}')
selected_overhaul = ongoing_overhaul
equipments = (
Select(StandardScope)
.outerjoin(StandardScope.oh_history) # Use outerjoin to handle None values
.join(StandardScope.workscope_groups)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag)
.filter(WorkscopeOHType.maintenance_type_id == selected_overhaul.maintenance_type_id)
.filter(
(StandardScope.is_alternating_oh == False) |
(StandardScope.oh_history == None) |
(StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != selected_overhaul.maintenance_type.name))
).distinct()
)
equipments = Select(StandardScope).outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag).filter(WorkscopeOHType.maintenance_type_id == selected_overhaul.maintenance_type_id).filter((StandardScope.is_alternating_oh == False) | (StandardScope.oh_history == None) | StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != selected_overhaul.maintenance_type.name)).distinct()
equipments = apply_not_deleted_filter(equipments, StandardScope)
results = await db_session.execute(equipments)
#Remaining days based on status
remaining_days = (selected_overhaul.start_date - current_date).days if selected_overhaul.status == "Upcoming" else (selected_overhaul.end_date - current_date).days
return {
"status": selected_overhaul.status,
"overhaul": {
"id": selected_overhaul.id,
"type": selected_overhaul.maintenance_type.name,
"start_date": selected_overhaul.start_date,
"end_date": selected_overhaul.end_date,
"duration_oh": selected_overhaul.duration_oh,
"crew_number": selected_overhaul.crew_number,
"remaining_days": remaining_days,
"equipment_count": len(results.scalars().all()),
},
}
remaining_days = (selected_overhaul.start_date - current_date).days if selected_overhaul.status == 'Upcoming' else (selected_overhaul.end_date - current_date).days
return {'status': selected_overhaul.status, 'overhaul': {'id': selected_overhaul.id, 'type': selected_overhaul.maintenance_type.name, 'start_date': selected_overhaul.start_date, 'end_date': selected_overhaul.end_date, 'duration_oh': selected_overhaul.duration_oh, 'crew_number': selected_overhaul.crew_number, 'remaining_days': remaining_days, 'equipment_count': len(results.scalars().all())}}
async def get_all_oh_with_history_service(*, db_session: DbSession):
query = Select(OverhaulScope).options(selectinload(OverhaulScope.maintenance_type)).where(OverhaulScope.wo_parent.isnot(None))

@ -1,35 +1,23 @@
from decimal import Decimal, getcontext
def get_material_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28
if not total_equipment: # Guard against division by zero
if not total_equipment:
return float(0)
if scope == "B":
result = Decimal("365539731101") / Decimal(str(total_equipment))
if scope == 'B':
result = Decimal('365539731101') / Decimal(str(total_equipment))
return float(result)
else:
result = Decimal("8565468127") / Decimal(str(total_equipment))
result = Decimal('8565468127') / Decimal(str(total_equipment))
return float(result)
return float(0)
def get_service_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28
if not total_equipment: # Guard against division by zero
if not total_equipment:
return float(0)
if scope == "B":
result = Decimal("36405830225") / Decimal(str(total_equipment))
if scope == 'B':
result = Decimal('36405830225') / Decimal(str(total_equipment))
return float(result)
else:
result = Decimal("36000000000") / Decimal(str(total_equipment))
result = Decimal('36000000000') / Decimal(str(total_equipment))
return float(result)
return float(0)

@ -1,10 +1 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
from src.config import RATELIMIT_STORAGE_URI
# limiter = Limiter(
# key_func=get_remote_address,
# default_limits=["120 per hour", "2880 per day"],
# strategy="fixed-window",
# storage_uri=RATELIMIT_STORAGE_URI,
# )

@ -1,77 +1,22 @@
"""
Soft delete utilities.
Provides reusable functions to convert hard-delete operations into soft-deletes.
The `soft_delete_record` function works with any model that inherits `SoftDeleteMixin`
(which is included in `DefaultMixin`).
Usage in service files:
from src.soft_delete import soft_delete_record, apply_not_deleted_filter
async def delete(*, db_session, record_id: str):
await soft_delete_record(db_session=db_session, model=MyModel, record_id=record_id)
async def get_all(*, db_session, ...):
query = Select(MyModel)
query = apply_not_deleted_filter(query, MyModel)
...
"""
import logging
from datetime import datetime
from typing import TypeVar, Type
from sqlalchemy import Select, Update
from sqlalchemy.ext.asyncio import AsyncSession
import pytz
from src.config import TIMEZONE
log = logging.getLogger(__name__)
T = TypeVar("T")
T = TypeVar('T')
def apply_not_deleted_filter(query: Select, model) -> Select:
"""
Appends a WHERE clause to exclude soft-deleted records.
Use this on all "get" queries to hide deleted data.
Example:
query = Select(OverhaulScope)
query = apply_not_deleted_filter(query, OverhaulScope)
"""
if hasattr(model, "deleted_at"):
if hasattr(model, 'deleted_at'):
return query.filter(model.deleted_at.is_(None))
return query
async def soft_delete_record(
*,
db_session: AsyncSession,
model: Type[T],
record_id: str,
id_column: str = "id",
) -> None:
"""
Soft-delete a record by setting its `deleted_at` timestamp.
Instead of DELETE, this performs an UPDATE.
Args:
db_session: The async database session.
model: The SQLAlchemy model class.
record_id: The value of the ID to match.
id_column: The name of the ID column (default: "id").
"""
async def soft_delete_record(*, db_session: AsyncSession, model: Type[T], record_id: str, id_column: str='id') -> None:
now = datetime.now(pytz.timezone(TIMEZONE))
id_col = getattr(model, id_column)
stmt = (
Update(model)
.where(id_col == record_id)
.values(deleted_at=now)
)
stmt = Update(model).where(id_col == record_id).values(deleted_at=now)
await db_session.execute(stmt)
await db_session.commit()
log.info(f"Soft-deleted {model.__tablename__} record: {record_id}")
log.info(f'Soft-deleted {model.__tablename__} record: {record_id}')

@ -1,43 +1,28 @@
from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String, Date
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
from src.models import DefaultMixin
class MasterSparePart(Base, DefaultMixin):
__tablename__ = "oh_ms_sparepart"
__tablename__ = 'oh_ms_sparepart'
assetnum = Column(String, nullable=False)
location_tag = Column(String, nullable=False)
stock = Column(Integer, nullable=False, default=0)
name = Column(String, nullable=False)
cost_per_stock = Column(Float, nullable=False)
unit = Column(String, nullable=False)
sparepart_procurements = relationship("MasterSparepartProcurement", lazy="selectin")
sparepart_procurements = relationship('MasterSparepartProcurement', lazy='selectin')
class MasterSparepartProcurement(Base, DefaultMixin):
__tablename__ = "oh_ms_sparepart_procurement"
sparepart_id = Column(
UUID(as_uuid=True),
ForeignKey("oh_ms_sparepart.id", ondelete="cascade"),
nullable=False,
)
__tablename__ = 'oh_ms_sparepart_procurement'
sparepart_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_sparepart.id', ondelete='cascade'), nullable=False)
quantity = Column(Integer, nullable=False)
status = Column(String, nullable=False)
eta_requisition = Column(Date, nullable=False)
eta_ordered = Column(Date, nullable=True)
eta_received = Column(Date, nullable=True)
class SparepartRemark(Base, DefaultMixin):
__tablename__ = "oh_ms_sparepart_remark"
__tablename__ = 'oh_ms_sparepart_remark'
itemnum = Column(String, nullable=False)
remark = Column(String, nullable=False)

@ -1,94 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends
from src.csrf_protect import csrf_protect
from src.database.core import CollectorDbSession
from src.database.service import (CommonParameters, DbSession, search_filter_sort_paginate)
from src.database.service import DbSession
from src.models import StandardResponse
from src.sparepart.schema import SparepartRemark
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .service import create_remark, get_spareparts_paginated
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("", response_model=StandardResponse[list])
@router.get('', response_model=StandardResponse[list])
async def get_sparepart(collector_db_session: CollectorDbSession, db_session: DbSession):
"""Get all scope activity pagination."""
# return
data = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session)
return StandardResponse(data=data, message='Data retrieved successfully')
return StandardResponse(
data=data,
message="Data retrieved successfully",
)
@router.post("", response_model=StandardResponse[SparepartRemark], dependencies=[Depends(csrf_protect)])
@router.post('', response_model=StandardResponse[SparepartRemark], dependencies=[Depends(csrf_protect)])
async def create_remark_route(collector_db_session: CollectorDbSession, db_session: DbSession, remark_in: SparepartRemark):
sparepart_remark = await create_remark(db_session=db_session, collector_db_session=collector_db_session, remark_in=remark_in)
return StandardResponse(
data=sparepart_remark,
message="Remark Created successfully"
)
# @router.post("", response_model=StandardResponse[ActivityMasterCreate])
# async def create_activity(db_session: DbSession, activity_in: ActivityMasterCreate):
# activity = await create(db_session=db_session, activty_in=activity_in)
# return StandardResponse(data=activity, message="Data created successfully")
# @router.get(
# "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster]
# )
# async def get_activity(db_session: DbSession, activity_id: str):
# activity = await get(db_session=db_session, activity_id=activity_id)
# if not activity:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(data=activity, message="Data retrieved successfully")
# @router.put(
# "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster]
# )
# async def update_scope(
# db_session: DbSession, activity_in: ActivityMasterCreate, activity_id
# ):
# activity = await get(db_session=db_session, activity_id=activity_id)
# if not activity:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(
# data=await update(
# db_session=db_session, activity=activity, activity_in=activity_in
# ),
# message="Data updated successfully",
# )
# @router.delete(
# "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster]
# )
# async def delete_scope(db_session: DbSession, activity_id: str):
# activity = await get(db_session=db_session, activity_id=activity_id)
# if not activity:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail=[{"msg": "A data with this id does not exist."}],
# )
# await delete(db_session=db_session, activity_id=activity_id)
# return StandardResponse(message="Data deleted successfully", data=activity)
return StandardResponse(data=sparepart_remark, message='Remark Created successfully')

@ -1,31 +1,23 @@
from dataclasses import dataclass
from datetime import date, datetime
from datetime import date
from enum import Enum
from typing import Any, Dict, List, Optional
from typing import List
from uuid import UUID
from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination
class ActivityMaster(DefultBase):
pass
class ActivityMasterDetail(DefultBase):
name: str
class ActivityMasterCreate(ActivityMaster):
description: str
class ActivityMasterTasks(DefultBase):
description: str
oh_type: str
class ActivityMasterRead(ActivityMaster):
id: UUID
workscope: str
@ -33,19 +25,17 @@ class ActivityMasterRead(ActivityMaster):
subsystem: str
tasks: List[ActivityMasterTasks]
class ActivityMasterPagination(Pagination):
items: List[ActivityMasterRead] = []
class ProcurementStatus(Enum):
PLANNED = "planned"
ORDERED = "ordered"
RECEIVED = "received"
CANCELLED = "cancelled"
PLANNED = 'planned'
ORDERED = 'ordered'
RECEIVED = 'received'
CANCELLED = 'cancelled'
@dataclass
class SparepartRequirement:
"""Sparepart requirement for equipment overhaul"""
sparepart_id: str
quantity_required: int
lead_time: int
@ -56,7 +46,6 @@ class SparepartRequirement:
@dataclass
class SparepartStock:
"""Current sparepart stock information"""
sparepart_id: str
sparepart_name: str
current_stock: int
@ -66,7 +55,6 @@ class SparepartStock:
@dataclass
class ProcurementRecord:
"""Purchase Order/Purchase Request record"""
po_pr_id: str
sparepart_id: str
sparepart_name: str
@ -78,7 +66,6 @@ class ProcurementRecord:
status: ProcurementStatus
po_vendor_delivery_date: date
class SparepartRemark(DefultBase):
itemnum: str
remark: str

File diff suppressed because one or more lines are too long

@ -1,6 +1,5 @@
from src.enums import OptimumOHEnum
class ScopeEquipmentType(OptimumOHEnum):
TEMP = "Temporary"
PERM = "Permanent"
TEMP = 'Temporary'
PERM = 'Permanent'

@ -1,71 +1,36 @@
from sqlalchemy import UUID, Column, Date, Float, ForeignKey, Integer, String, Boolean
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
from src.models import DefaultMixin
class StandardScope(Base, DefaultMixin):
__tablename__ = "oh_ms_standard_scope"
__tablename__ = 'oh_ms_standard_scope'
location_tag = Column(String, nullable=False)
is_alternating_oh = Column(Boolean, nullable=False, default=False)
assigned_date = Column(Date, nullable=True)
service_cost = Column(Float, nullable=True)
master_equipment = relationship(
"MasterEquipment",
lazy="selectin",
primaryjoin="and_(StandardScope.location_tag == foreign(MasterEquipment.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)
oh_history = relationship(
"EquipmentOHHistory",
lazy="selectin",
primaryjoin="and_(StandardScope.location_tag == foreign(EquipmentOHHistory.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)
workscope_groups = relationship(
"EquipmentWorkscopeGroup",
lazy="selectin",
primaryjoin="and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))",
uselist=True, # Add this if it's a one-to-one relationship
)
master_equipment = relationship('MasterEquipment', lazy='selectin', primaryjoin='and_(StandardScope.location_tag == foreign(MasterEquipment.location_tag))', uselist=False)
oh_history = relationship('EquipmentOHHistory', lazy='selectin', primaryjoin='and_(StandardScope.location_tag == foreign(EquipmentOHHistory.location_tag))', uselist=False)
workscope_groups = relationship('EquipmentWorkscopeGroup', lazy='selectin', primaryjoin='and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))', uselist=True)
class EquipmentOHHistory(Base, DefaultMixin):
__tablename__ = "oh_ms_equipment_oh_history"
__tablename__ = 'oh_ms_equipment_oh_history'
location_tag = Column(String, nullable=False)
last_oh_date = Column(Date, nullable=True)
last_oh_type = Column(String, nullable=True)
class MasterEquipment(Base, DefaultMixin):
__tablename__ = "ms_equipment_master"
__tablename__ = 'ms_equipment_master'
id = Column(UUID(as_uuid=True), primary_key=True, index=True)
parent_id = Column(
UUID(as_uuid=True),
ForeignKey("ms_equipment_master.id", ondelete="CASCADE"),
nullable=True,
)
parent_id = Column(UUID(as_uuid=True), ForeignKey('ms_equipment_master.id', ondelete='CASCADE'), nullable=True)
assetnum = Column(String, nullable=True)
system_tag = Column(String, nullable=True)
location_tag = Column(String, nullable=True)
name = Column(String, nullable=True)
equipment_tree_id = Column(
UUID(as_uuid=True), ForeignKey("ms_equipment_tree.id"), nullable=True
)
equipment_tree = relationship("MasterEquipmentTree", backref="master_equipments")
parent = relationship("MasterEquipment", remote_side=[id], lazy="selectin")
equipment_tree_id = Column(UUID(as_uuid=True), ForeignKey('ms_equipment_tree.id'), nullable=True)
equipment_tree = relationship('MasterEquipmentTree', backref='master_equipments')
parent = relationship('MasterEquipment', remote_side=[id], lazy='selectin')
class MasterEquipmentTree(Base, DefaultMixin):
__tablename__ = "ms_equipment_tree"
__tablename__ = 'ms_equipment_tree'
level_no = Column(Integer)

@ -1,92 +1,31 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List
from fastapi import APIRouter, Depends
from fastapi.params import Query
from src.auth.service import CurrentUser
from src.database.core import DbSession, CollectorDbSession
from src.database.service import CommonParameters, search_filter_sort_paginate
from src.database.service import CommonParameters
from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (MasterEquipmentPagination, ScopeEquipmentCreate,
ScopeEquipmentPagination, ScopeEquipmentRead,
ScopeEquipmentUpdate)
from .service import (create, delete, get_all, get_all_master_equipment, update, get_history_standard_scope_wo_service)
from .schema import MasterEquipmentPagination, ScopeEquipmentCreate, ScopeEquipmentPagination
from .service import create, get_all, get_all_master_equipment, get_history_standard_scope_wo_service
from uuid import UUID
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get("", response_model=StandardResponse[ScopeEquipmentPagination])
@router.get('', response_model=StandardResponse[ScopeEquipmentPagination])
async def get_scope_equipments(common: CommonParameters, scope_name: str=Query(None)):
"""Get all scope pagination."""
# return
data = await get_all(common=common, oh_scope=scope_name)
return StandardResponse(data=data, message='Data retrieved successfully')
return StandardResponse(
data=data,
message="Data retrieved successfully",
)
@router.get(
"/available/{scope_name}",
response_model=StandardResponse[MasterEquipmentPagination],
)
@router.get('/available/{scope_name}', response_model=StandardResponse[MasterEquipmentPagination])
async def get_master_equipment(common: CommonParameters, scope_name: str):
results = await get_all_master_equipment(common=common, scope_name=scope_name)
return StandardResponse(data=results, message='Data retrieved successfully')
return StandardResponse(data=results, message="Data retrieved successfully")
@router.post("", response_model=StandardResponse[List[str]])
async def create_scope_equipment(
db_session: DbSession, scope_equipment_in: ScopeEquipmentCreate
):
@router.post('', response_model=StandardResponse[List[str]])
async def create_scope_equipment(db_session: DbSession, scope_equipment_in: ScopeEquipmentCreate):
scope = await create(db_session=db_session, scope_equipment_in=scope_equipment_in)
return StandardResponse(data=scope, message='Data created successfully')
return StandardResponse(data=scope, message="Data created successfully")
@router.get("/history/{oh_session_id}", response_model=StandardResponse[List[dict]])
async def get_history_standard_scope_wo(
db_session: DbSession, collector_db_session:CollectorDbSession, oh_session_id:UUID):
@router.get('/history/{oh_session_id}', response_model=StandardResponse[List[dict]])
async def get_history_standard_scope_wo(db_session: DbSession, collector_db_session: CollectorDbSession, oh_session_id: UUID):
results = await get_history_standard_scope_wo_service(db_session=db_session, collector_db_session=collector_db_session, oh_session_id=oh_session_id)
return StandardResponse(data=results, message="Data retrieved successfully")
# @router.put("/{assetnum}", response_model=StandardResponse[ScopeEquipmentRead])
# async def update_scope_equipment(
# db_session: DbSession, assetnum: str, scope__equipment_in: ScopeEquipmentUpdate
# ):
# scope_equipment = await get_by_assetnum(db_session=db_session, assetnum=assetnum)
# if not scope_equipment:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(
# data=await update(
# db_session=db_session,
# scope_equipment=scope_equipment,
# scope__equipment_in=scope__equipment_in,
# ),
# message="Data updated successfully",
# )
# @router.delete("/{assetnum}", response_model=StandardResponse[None])
# async def delete_scope_equipment(db_session: DbSession, assetnum: str):
# scope_equipment = await get_by_assetnum(db_session=db_session, assetnum=assetnum)
# if not scope_equipment:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail=[{"msg": "A data with this id does not exist."}],
# )
# await delete(db_session=db_session, assetnum=assetnum)
# return StandardResponse(message="Data deleted successfully", data=None)
return StandardResponse(data=results, message='Data retrieved successfully')

@ -1,23 +1,16 @@
from datetime import datetime
from typing import List, Optional, ForwardRef
from uuid import UUID
from pydantic import Field, computed_field, field_validator, validator
from pydantic import Field
from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead
from .enum import ScopeEquipmentType
class MasterEquipmentBase(DefultBase):
name: Optional[str] = Field(None, title="Name")
location_tag: Optional[str] = Field(None, title="Location Tag")
name: Optional[str] = Field(None, title='Name')
location_tag: Optional[str] = Field(None, title='Location Tag')
class ScopeEquipmentBase(DefultBase):
scope_overhaul: Optional[str] = Field(None, title="Scope ID")
scope_overhaul: Optional[str] = Field(None, title='Scope ID')
class ScopeEquipmentCreate(DefultBase):
assetnums: List[str]
@ -25,10 +18,8 @@ class ScopeEquipmentCreate(DefultBase):
removal_date: Optional[datetime] = Field(None)
type: Optional[str] = Field(ScopeEquipmentType.PERM)
class ScopeEquipmentUpdate(ScopeEquipmentBase):
assetnum: Optional[str] = Field(None, title="Asset Number")
assetnum: Optional[str] = Field(None, title='Asset Number')
class ScopeEquipmentRead(ScopeEquipmentBase):
id: UUID
@ -36,21 +27,19 @@ class ScopeEquipmentRead(ScopeEquipmentBase):
assigned_date: datetime
master_equipment: Optional[MasterEquipmentBase] = Field(None)
class ScopeEquipmentPagination(DefultBase):
items: List[ScopeEquipmentRead] = []
total: int
class MasterEquipmentRead(DefultBase):
assetnum: Optional[str] = Field(None, title="Asset Number")
location_tag: Optional[str] = Field(None, title="Location Tag")
assetnum: Optional[str] = Field(None, title='Asset Number')
location_tag: Optional[str] = Field(None, title='Location Tag')
name: str
EquipmentMasterTreeRef = ForwardRef("MasterEquipmentTree")
EquipmentMasterTreeRef = ForwardRef('MasterEquipmentTree')
class MasterEquipmentTree(MasterEquipmentRead):
parent_id: Optional[UUID]
parent: Optional[EquipmentMasterTreeRef] = Field(None) # type: ignore
parent: Optional[EquipmentMasterTreeRef] = Field(None)
class MasterEquipmentPagination(Pagination):
items: List[MasterEquipmentRead] = []

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save