Update: Sheet 8 & 11 compliance + changing api router due to error unauthorized when access some pages

oh_security
TAMDeveloper13 3 months ago
parent 2a57ac8bec
commit 2615ff9a6e

@ -174,7 +174,7 @@ calculation_router.include_router(
authenticated_api_router.include_router(calculation_router) authenticated_api_router.include_router(calculation_router)
api_router.include_router( authenticated_api_router.include_router(
get_calculation, get_calculation,
prefix="/calculation/time-constraint", prefix="/calculation/time-constraint",
tags=["calculation", "time_constraint"], tags=["calculation", "time_constraint"],

@ -17,6 +17,7 @@ from src.overhaul_scope.model import MaintenanceType
from .model import EquipmentWorkscopeGroup from .model import EquipmentWorkscopeGroup
from .schema import ScopeEquipmentJobCreate 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]: # async def get(*, db_session: DbSession, scope_equipment_activity_id: str) -> Optional[ScopeEquipmentActivity]:
# """Returns a document based on the given document id.""" # """Returns a document based on the given document id."""
@ -58,6 +59,7 @@ async def get_all(*, common, location_tag: str, scope: Optional[str] = None):
Select(EquipmentWorkscopeGroup) Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(None)) .where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(None))
) )
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup)
if scope: if scope:
query = ( query = (

@ -1,4 +1,5 @@
import logging import logging
import asyncio
import time import time
from src.context import set_request_id, reset_request_id, get_request_id from src.context import set_request_id, reset_request_id, get_request_id
from contextvars import ContextVar from contextvars import ContextVar
@ -29,7 +30,7 @@ from src.database.core import async_session, engine, async_collector_session
from src.enums import ResponseStatus from src.enums import ResponseStatus
from src.exceptions import handle_exception from src.exceptions import handle_exception
from src.logging import configure_logging from src.logging import configure_logging
from src.middleware import RequestValidationMiddleware from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware
from src.csrf_protect import CSRFProtectMiddleware from src.csrf_protect import CSRFProtectMiddleware
from src.rate_limiter import limiter from src.rate_limiter import limiter
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
@ -40,11 +41,13 @@ log = logging.getLogger(__name__)
# we configure the logging level and format # we configure the logging level and format
configure_logging() configure_logging()
REQUEST_TIMEOUT_SECONDS = 20
# we create the ASGI for the app # we create the ASGI for the app
app = FastAPI( app = FastAPI(
openapi_url="", openapi_url="",
title="LCCA API", title="Optimum OH API",
description="Welcome to LCCA's API documentation!", description="Welcome to Optimum OH's API documentation!",
version="0.1.0", version="0.1.0",
) )
@ -58,10 +61,16 @@ app.add_exception_handler(RateLimitExceeded, handle_exception)
app.state.limiter = limiter app.state.limiter = limiter
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(SlowAPIMiddleware) app.add_middleware(SlowAPIMiddleware)
app.add_middleware(RequestTimeoutMiddleware, timeout=REQUEST_TIMEOUT_SECONDS)
# credentials: "include", # credentials: "include",
@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)

@ -424,3 +424,67 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
request._receive = receive request._receive = receive
return await call_next(request) return await call_next(request)
import threading
import asyncio
import json
import logging
REQUEST_TIMEOUT_SECONDS = 20
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":
await self.app(scope, receive, send)
return
response_started = False
async def send_wrapper(message):
nonlocal response_started
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,
)
except asyncio.TimeoutError:
if not response_started:
log.warning(
"Request timeout (%ds): %s %s",
self.timeout,
scope.get("method", ""),
scope.get("path", ""),
)
body = json.dumps({
"status": False,
"message": f"Request timeout. Processing exceeded {self.timeout} seconds.",
"data": None,
}).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,
})

@ -49,7 +49,14 @@ class UUIDMixin:
) )
class DefaultMixin(TimeStampMixin, UUIDMixin): 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""" """Default mixin"""
pass pass

@ -3,6 +3,7 @@ import datetime
from typing import List, Optional from typing import List, Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import HTTPException, status
from sqlalchemy import Delete, Select, and_, func, select from sqlalchemy import Delete, Select, and_, func, select
from sqlalchemy import update as sqlUpdate from sqlalchemy import update as sqlUpdate
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
@ -30,6 +31,7 @@ from .schema import (OverhaulActivityCreate, OverhaulActivityRead,
import json import json
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.maximo.service import get_cm_cost_summary, get_oh_cost_summary 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( async def get(
*, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID] = None *, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID] = None
@ -40,6 +42,7 @@ async def get(
.where(OverhaulActivity.assetnum == assetnum) .where(OverhaulActivity.assetnum == assetnum)
.options(joinedload(OverhaulActivity.equipment)) .options(joinedload(OverhaulActivity.equipment))
) )
query = apply_not_deleted_filter(query, OverhaulActivity)
if overhaul_session_id: if overhaul_session_id:
query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id) query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
@ -105,6 +108,7 @@ async def get_all(
(StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name)) (StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name))
).distinct() ).distinct()
) )
query = apply_not_deleted_filter(query, StandardScope)
if location_tag: if location_tag:
query = query.filter(StandardScope.location_tag == location_tag) query = query.filter(StandardScope.location_tag == location_tag)
@ -427,6 +431,7 @@ async def get_all_by_session_id(*, db_session: DbSession, overhaul_session_id):
.options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent)))) .options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent))))
.options(selectinload(OverhaulActivity.overhaul_scope)) .options(selectinload(OverhaulActivity.overhaul_scope))
) )
query = apply_not_deleted_filter(query, OverhaulActivity)
results = await db_session.execute(query) results = await db_session.execute(query)
@ -437,14 +442,34 @@ async def get_all_by_session_id(*, db_session: DbSession, overhaul_session_id):
async def update( async def update(
*, *,
db_session: DbSession, db_session: DbSession,
activity: OverhaulActivity, activity_id: str,
overhaul_session_id: Optional[UUID] = None,
overhaul_activity_in: OverhaulActivityUpdate overhaul_activity_in: OverhaulActivityUpdate
): ):
"""Updates a document.""" """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update."""
data = overhaul_activity_in.model_dump() query = (
Select(OverhaulActivity)
.where(OverhaulActivity.assetnum == activity_id)
.options(joinedload(OverhaulActivity.equipment))
.with_for_update()
)
query = apply_not_deleted_filter(query, OverhaulActivity)
update_data = overhaul_activity_in.model_dump(exclude_defaults=True) 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.",
)
await db_session.refresh(activity)
update_data = overhaul_activity_in.model_dump(exclude_defaults=True)
update_model(activity, update_data) update_model(activity, update_data)
await db_session.commit() await db_session.commit()

@ -13,8 +13,9 @@ from src.database.service import search_filter_sort_paginate
from src.workscope_group.model import MasterActivity from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.model import MaintenanceType from src.overhaul_scope.model import MaintenanceType
from .model import EquipmentWorkscopeGroup from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from .schema import OverhaulJobCreate 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): async def get_all(*, common, location_tag: str, scope: Optional[str] = None):
@ -23,6 +24,7 @@ async def get_all(*, common, location_tag: str, scope: Optional[str] = None):
Select(EquipmentWorkscopeGroup) Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag) .where(EquipmentWorkscopeGroup.location_tag == location_tag)
) )
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup)
if scope: if scope:
query = ( query = (
@ -84,18 +86,14 @@ async def delete(
AuthorizationError: If user lacks delete permission AuthorizationError: If user lacks delete permission
""" """
try: try:
# Check if job exists # Soft-delete the record
scope_job = await db_session.get(OverhaulJob, overhaul_job_id) deleted = await soft_delete_record(db_session, EquipmentWorkscopeGroup, overhaul_job_id)
if not scope_job: if not deleted:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.", detail="A data with this id does not exist.",
) )
# Perform deletion
await db_session.delete(scope_job)
await db_session.commit()
return True return True
except Exception as e: except Exception as e:

@ -10,6 +10,7 @@ from src.database.service import search_filter_sort_paginate
from src.utils import update_model from src.utils import update_model
from src.scope_equipment_job.model import ScopeEquipmentJob from src.scope_equipment_job.model import ScopeEquipmentJob
from src.overhaul_activity.model import OverhaulActivity from src.overhaul_activity.model import OverhaulActivity
from src.soft_delete import apply_not_deleted_filter, soft_delete_record
from .model import OverhaulSchedule from .model import OverhaulSchedule
from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate
@ -18,6 +19,7 @@ from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate
async def get_all(*, common): async def get_all(*, common):
"""Returns all documents.""" """Returns all documents."""
query = Select(OverhaulSchedule).order_by(OverhaulSchedule.start.desc()) query = Select(OverhaulSchedule).order_by(OverhaulSchedule.start.desc())
query = apply_not_deleted_filter(query, OverhaulSchedule)
results = await search_filter_sort_paginate(model=query, **common) results = await search_filter_sort_paginate(model=query, **common)
return results return results
@ -38,7 +40,11 @@ async def create(
async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_job_in: OverhaulScheduleUpdate): async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_job_in: OverhaulScheduleUpdate):
"""Updates a document.""" """Updates a document."""
data = overhaul_job_in.model_dump() data = overhaul_job_in.model_dump()
overhaul_schedule = await db_session.get(OverhaulSchedule, overhaul_schedule_id) 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_data = overhaul_job_in.model_dump(exclude_defaults=True)
@ -50,7 +56,5 @@ async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_j
async def delete(*, db_session: DbSession, overhaul_schedule_id: str): async def delete(*, db_session: DbSession, overhaul_schedule_id: str):
"""Deletes a document.""" """Soft-deletes a document."""
query = Delete(OverhaulSchedule).where(OverhaulSchedule.id == overhaul_schedule_id) await soft_delete_record(db_session=db_session, model=OverhaulSchedule, record_id=overhaul_schedule_id)
await db_session.execute(query)
await db_session.commit()

@ -58,23 +58,15 @@ async def update_scope(
scope_in: ScopeUpdate, scope_in: ScopeUpdate,
current_user: CurrentUser, current_user: CurrentUser,
): ):
scope = await get(db_session=db_session, overhaul_session_id=scope_id, for_update=True)
if not scope:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return StandardResponse( return StandardResponse(
data=await update(db_session=db_session, scope=scope, scope_in=scope_in), data=await update(db_session=db_session, scope_id=scope_id, scope_in=scope_in),
message="Data updated successfully", message="Data updated successfully",
) )
@router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope))]) @router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope))])
async def delete_scope(db_session: DbSession, scope_id: str): async def delete_scope(db_session: DbSession, scope_id: str):
scope = await get(db_session=db_session, overhaul_session_id=scope_id, for_update=True) scope = await get(db_session=db_session, overhaul_session_id=scope_id)
if not scope: if not scope:
raise HTTPException( raise HTTPException(

@ -18,20 +18,21 @@ from .schema import ScopeCreate, ScopeUpdate
from .utils import get_material_cost, get_service_cost from .utils import get_material_cost, get_service_cost
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from src.soft_delete import apply_not_deleted_filter, soft_delete_record
async def get( async def get(
*, db_session: DbSession, overhaul_session_id: UUID, for_update: bool = False *, db_session: DbSession, overhaul_session_id: UUID
) -> Optional[OverhaulScope]: ) -> Optional[OverhaulScope]:
"""Returns a document based on the given document id.""" """Returns a document based on the given document id."""
query = Select(OverhaulScope).filter(OverhaulScope.id == overhaul_session_id).options(selectinload(OverhaulScope.maintenance_type)) query = Select(OverhaulScope).filter(OverhaulScope.id == overhaul_session_id).options(selectinload(OverhaulScope.maintenance_type))
if for_update: query = apply_not_deleted_filter(query, OverhaulScope)
query = query.with_for_update()
result = await db_session.execute(query) result = await db_session.execute(query)
return result.scalars().one_or_none() return result.scalars().one_or_none()
async def get_prev_oh(*, db_session: DbSession, overhaul_session: OverhaulScope) -> Optional[OverhaulScope]: async def get_prev_oh(*, db_session: DbSession, overhaul_session: OverhaulScope) -> Optional[OverhaulScope]:
"""Returns a document based on the given document id.""" """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 = 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) result = await db_session.execute(result)
return result.scalars().one_or_none() return result.scalars().one_or_none()
@ -39,6 +40,7 @@ async def get_prev_oh(*, db_session: DbSession, overhaul_session: OverhaulScope)
async def get_all(*, common, scope_name: Optional[str] = None): async def get_all(*, common, scope_name: Optional[str] = None):
"""Returns all documents.""" """Returns all documents."""
query = Select(OverhaulScope) query = Select(OverhaulScope)
query = apply_not_deleted_filter(query, OverhaulScope)
if scope_name: if scope_name:
query = query.filter(OverhaulScope.maintenance_type.name == scope_name) query = query.filter(OverhaulScope.maintenance_type.name == scope_name)
@ -136,12 +138,27 @@ async def create(*, db_session: DbSession, scope_in: ScopeCreate):
return overhaul_session return overhaul_session
async def update(*, db_session: DbSession, scope: OverhaulScope, scope_in: ScopeUpdate): async def update(*, db_session: DbSession, scope_id: str, scope_in: ScopeUpdate):
"""Updates a document.""" """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update."""
data = scope_in.model_dump() 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()
update_data = scope_in.model_dump(exclude_defaults=True) if not scope:
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) update_model(scope, update_data)
await db_session.commit() await db_session.commit()
@ -150,10 +167,8 @@ async def update(*, db_session: DbSession, scope: OverhaulScope, scope_in: Scope
async def delete(*, db_session: DbSession, scope_id: str): async def delete(*, db_session: DbSession, scope_id: str):
"""Deletes a document.""" """Soft-deletes a document."""
query = Delete(OverhaulScope).where(OverhaulScope.id == scope_id) await soft_delete_record(db_session=db_session, model=OverhaulScope, record_id=scope_id)
await db_session.execute(query)
await db_session.commit()
async def get_overview_overhaul(*, db_session: DbSession): async def get_overview_overhaul(*, db_session: DbSession):
@ -168,6 +183,7 @@ async def get_overview_overhaul(*, db_session: DbSession):
OverhaulScope.end_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_result = await db_session.execute(ongoing_query.options(selectinload(OverhaulScope.maintenance_type)))
ongoing_overhaul = ongoing_result.scalar_one_or_none() ongoing_overhaul = ongoing_result.scalar_one_or_none()
@ -180,6 +196,7 @@ async def get_overview_overhaul(*, db_session: DbSession):
.order_by(OverhaulScope.start_date.asc()) .order_by(OverhaulScope.start_date.asc())
.limit(1) .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_result = await db_session.execute(next_overhaul_query.options(selectinload(OverhaulScope.maintenance_type)))
next_overhaul = next_result.scalar_one_or_none() next_overhaul = next_result.scalar_one_or_none()
@ -208,6 +225,7 @@ async def get_overview_overhaul(*, db_session: DbSession):
(StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != selected_overhaul.maintenance_type.name)) (StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != selected_overhaul.maintenance_type.name))
).distinct() ).distinct()
) )
equipments = apply_not_deleted_filter(equipments, StandardScope)
results = await db_session.execute(equipments) results = await db_session.execute(equipments)
@ -230,5 +248,6 @@ async def get_overview_overhaul(*, db_session: DbSession):
async def get_all_oh_with_history_service(*, db_session: DbSession): 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)) query = Select(OverhaulScope).options(selectinload(OverhaulScope.maintenance_type)).where(OverhaulScope.wo_parent.isnot(None))
query = apply_not_deleted_filter(query, OverhaulScope)
results = await db_session.execute(query) results = await db_session.execute(query)
return results.scalars().all() return results.scalars().all()

@ -0,0 +1,77 @@
"""
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")
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"):
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").
"""
now = datetime.now(pytz.timezone(TIMEZONE))
id_col = getattr(model, id_column)
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}")

@ -20,6 +20,7 @@ from src.overhaul_scope.service import get as get_scope, get_overview_overhaul
from src.overhaul_scope.service import get_prev_oh from src.overhaul_scope.service import get_prev_oh
from src.sparepart.model import SparepartRemark from src.sparepart.model import SparepartRemark
from src.sparepart.schema import ProcurementRecord, ProcurementStatus, SparepartRequirement, SparepartStock from src.sparepart.schema import ProcurementRecord, ProcurementStatus, SparepartRequirement, SparepartStock
from src.soft_delete import apply_not_deleted_filter
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -380,7 +381,7 @@ async def get_spareparts_paginated(*, db_session, collector_db_session):
) )
sparepart_remark = (await db_session.execute( sparepart_remark = (await db_session.execute(
select(SparepartRemark) apply_not_deleted_filter(select(SparepartRemark), SparepartRemark)
)).scalars().all() )).scalars().all()
sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark} sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark}
@ -946,7 +947,7 @@ async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_s
sparepart_stocks_query = await db_session.execute(query) sparepart_stocks_query = await db_session.execute(query)
sparepart_remark = (await app_db_session.execute( sparepart_remark = (await app_db_session.execute(
select(SparepartRemark) apply_not_deleted_filter(select(SparepartRemark), SparepartRemark)
)).scalars().all() )).scalars().all()
sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark} sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark}
@ -1398,7 +1399,10 @@ ORDER BY po.item_num, po.pr_issue_date DESC;
async def create_remark(*, db_session, collector_db_session, remark_in): async def create_remark(*, db_session, collector_db_session, remark_in):
# Step 1: Check if remark already exists for this itemnum # Step 1: Check if remark already exists for this itemnum
result = await db_session.execute( result = await db_session.execute(
select(SparepartRemark).where(SparepartRemark.itemnum == remark_in.itemnum) apply_not_deleted_filter(
select(SparepartRemark).where(SparepartRemark.itemnum == remark_in.itemnum),
SparepartRemark
)
) )
existing_remark = result.scalar_one_or_none() existing_remark = result.scalar_one_or_none()

@ -13,6 +13,7 @@ from src.database.service import CommonParameters, search_filter_sort_paginate
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from src.standard_scope.enum import ScopeEquipmentType from src.standard_scope.enum import ScopeEquipmentType
from src.standard_scope.model import EquipmentOHHistory from src.standard_scope.model import EquipmentOHHistory
from src.soft_delete import apply_not_deleted_filter, soft_delete_record
from src.workorder.model import MasterWorkOrder from src.workorder.model import MasterWorkOrder
from src.equipment_workscope_group.model import EquipmentWorkscopeGroup from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from src.workscope_group.model import MasterActivity from src.workscope_group.model import MasterActivity
@ -30,6 +31,7 @@ async def get_by_location_tag(*, db_session: DbSession, location_tag: str):
.filter(StandardScope.location_tag == location_tag) .filter(StandardScope.location_tag == location_tag)
.options(selectinload(StandardScope.master_equipment)) .options(selectinload(StandardScope.master_equipment))
) )
query = apply_not_deleted_filter(query, StandardScope)
result = await db_session.execute(query) result = await db_session.execute(query)
return result.unique().scalars().one_or_none() return result.unique().scalars().one_or_none()
@ -40,6 +42,7 @@ async def get_all(*, common, oh_scope: Optional[str] = None):
query = Select(StandardScope).options( query = Select(StandardScope).options(
selectinload(StandardScope.master_equipment) selectinload(StandardScope.master_equipment)
) )
query = apply_not_deleted_filter(query, StandardScope)
query = query.order_by(desc(StandardScope.created_at)).options(selectinload(StandardScope.master_equipment)) query = query.order_by(desc(StandardScope.created_at)).options(selectinload(StandardScope.master_equipment))
@ -90,6 +93,7 @@ async def get_by_oh_session_id(*, db_session: DbSession, oh_session_id: UUID):
(StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name)) (StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name))
).distinct() ).distinct()
) )
query = apply_not_deleted_filter(query, StandardScope)
result = await db_session.execute(query) result = await db_session.execute(query)
return result.scalars().all(), overhaul return result.scalars().all(), overhaul
@ -147,14 +151,29 @@ async def create(*, db_session: DbSession, scope_equipment_in: ScopeEquipmentCre
async def update( async def update(
*, *,
db_session: DbSession, db_session: DbSession,
scope_equipment: StandardScope, location_tag: str,
scope_equipment_in: ScopeEquipmentUpdate scope_equipment_in: ScopeEquipmentUpdate
): ):
"""Updates a document.""" """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update."""
data = scope_equipment_in.model_dump() query = (
Select(StandardScope)
.filter(StandardScope.location_tag == location_tag)
.options(selectinload(StandardScope.master_equipment))
.with_for_update()
)
query = apply_not_deleted_filter(query, StandardScope)
result = await db_session.execute(query)
scope_equipment = result.unique().scalars().one_or_none()
update_data = scope_equipment_in.model_dump(exclude_defaults=True) if not scope_equipment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
await db_session.refresh(scope_equipment)
update_data = scope_equipment_in.model_dump(exclude_defaults=True)
update_model(scope_equipment, update_data) update_model(scope_equipment, update_data)
await db_session.commit() await db_session.commit()
@ -163,11 +182,11 @@ async def update(
async def delete(*, db_session: DbSession, assetnum: str): async def delete(*, db_session: DbSession, assetnum: str):
"""Deletes a document.""" """Soft-deletes a document."""
query = Delete(StandardScope).where(StandardScope.assetnum == assetnum) result = await get_by_location_tag(db_session=db_session, location_tag=assetnum)
await db_session.execute(query) if result:
await db_session.commit() from src.soft_delete import soft_delete_record
await soft_delete_record(db_session=db_session, model=StandardScope, record_id=str(result.id))
return assetnum return assetnum
async def get_by_oh_scope( async def get_by_oh_scope(
@ -202,6 +221,7 @@ async def get_all_master_equipment(*, common: CommonParameters, scope_name):
] ]
query = Select(MasterEquipment).filter(MasterEquipment.location_tag.is_not(None)) query = Select(MasterEquipment).filter(MasterEquipment.location_tag.is_not(None))
query = apply_not_deleted_filter(query, MasterEquipment)
# Only add not_in filter if there are items in equipments_scope # Only add not_in filter if there are items in equipments_scope
if equipments_scope: if equipments_scope:
@ -217,6 +237,7 @@ async def get_equipment_level_by_no(*, db_session: DbSession, level: int):
.join(MasterEquipment.equipment_tree) .join(MasterEquipment.equipment_tree)
.where(MasterEquipmentTree.level_no == level) .where(MasterEquipmentTree.level_no == level)
) )
query = apply_not_deleted_filter(query, MasterEquipment)
result = await db_session.execute(query) result = await db_session.execute(query)
return result.scalars().all() return result.scalars().all()

@ -53,17 +53,9 @@ async def get_activity(db_session: DbSession, activity_id: str):
async def update_scope( async def update_scope(
db_session: DbSession, activity_in: ActivityMasterCreate, activity_id 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( return StandardResponse(
data=await update( data=await update(
db_session=db_session, activity=activity, activity_in=activity_in db_session=db_session, activity_id=activity_id, activity_in=activity_in
), ),
message="Data updated successfully", message="Data updated successfully",
) )

@ -1,5 +1,6 @@
from typing import Optional from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import Delete, Select from sqlalchemy import Delete, Select
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
@ -7,6 +8,7 @@ from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.utils import update_model from src.utils import update_model
from src.soft_delete import apply_not_deleted_filter, soft_delete_record
from .model import MasterActivity from .model import MasterActivity
from .schema import ActivityMaster, ActivityMasterCreate from .schema import ActivityMaster, ActivityMasterCreate
@ -14,12 +16,15 @@ from .schema import ActivityMaster, ActivityMasterCreate
async def get(*, db_session: DbSession, activity_id: str) -> Optional[ActivityMaster]: async def get(*, db_session: DbSession, activity_id: str) -> Optional[ActivityMaster]:
"""Returns a document based on the given document id.""" """Returns a document based on the given document id."""
result = await db_session.get(MasterActivity, activity_id) query = Select(MasterActivity).filter(MasterActivity.id == activity_id)
return result query = apply_not_deleted_filter(query, MasterActivity)
result = await db_session.execute(query)
return result.scalars().one_or_none()
async def get_all(common: CommonParameters): async def get_all(common: CommonParameters):
query = Select(MasterActivity) query = Select(MasterActivity)
query = apply_not_deleted_filter(query, MasterActivity)
results = await search_filter_sort_paginate(model=query, **common) results = await search_filter_sort_paginate(model=query, **common)
@ -36,14 +41,25 @@ async def create(*, db_session: DbSession, activty_in: ActivityMasterCreate):
async def update( async def update(
*, *,
db_session: DbSession, db_session: DbSession,
activity: MasterActivity, activity_id: str,
activity_in: ActivityMasterCreate activity_in: ActivityMasterCreate
): ):
"""Updates a document.""" """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update."""
data = activity_in.model_dump() query = Select(MasterActivity).filter(MasterActivity.id == activity_id)
query = apply_not_deleted_filter(query, MasterActivity)
query = query.with_for_update()
result = await db_session.execute(query)
activity = result.scalars().one_or_none()
update_data = activity_in.model_dump(exclude_defaults=True) if not activity:
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 = activity_in.model_dump(exclude_defaults=True)
update_model(activity, update_data) update_model(activity, update_data)
await db_session.commit() await db_session.commit()
@ -52,7 +68,5 @@ async def update(
async def delete(*, db_session: DbSession, activity_id: str): async def delete(*, db_session: DbSession, activity_id: str):
"""Deletes a document.""" """Soft-deletes a document."""
activity = await db_session.get(MasterActivity, activity_id) await soft_delete_record(db_session=db_session, model=MasterActivity, record_id=activity_id)
await db_session.delete(activity)
await db_session.commit()

@ -1,5 +1,6 @@
from typing import Optional from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import Delete, Select from sqlalchemy import Delete, Select
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
@ -7,6 +8,7 @@ from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.utils import update_model from src.utils import update_model
from src.soft_delete import apply_not_deleted_filter, soft_delete_record
from .model import MasterActivity from .model import MasterActivity
from .schema import ActivityMaster, ActivityMasterCreate from .schema import ActivityMaster, ActivityMasterCreate
@ -14,12 +16,15 @@ from .schema import ActivityMaster, ActivityMasterCreate
async def get(*, db_session: DbSession, activity_id: str) -> Optional[ActivityMaster]: async def get(*, db_session: DbSession, activity_id: str) -> Optional[ActivityMaster]:
"""Returns a document based on the given document id.""" """Returns a document based on the given document id."""
result = await db_session.get(MasterActivity, activity_id) query = Select(MasterActivity).filter(MasterActivity.id == activity_id)
return result query = apply_not_deleted_filter(query, MasterActivity)
result = await db_session.execute(query)
return result.scalars().one_or_none()
async def get_all(common: CommonParameters): async def get_all(common: CommonParameters):
query = Select(MasterActivity) query = Select(MasterActivity)
query = apply_not_deleted_filter(query, MasterActivity)
results = await search_filter_sort_paginate(model=query, **common) results = await search_filter_sort_paginate(model=query, **common)
@ -36,14 +41,25 @@ async def create(*, db_session: DbSession, activty_in: ActivityMasterCreate):
async def update( async def update(
*, *,
db_session: DbSession, db_session: DbSession,
activity: MasterActivity, activity_id: str,
activity_in: ActivityMasterCreate activity_in: ActivityMasterCreate
): ):
"""Updates a document.""" """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update."""
data = activity_in.model_dump() query = Select(MasterActivity).filter(MasterActivity.id == activity_id)
query = apply_not_deleted_filter(query, MasterActivity)
query = query.with_for_update()
result = await db_session.execute(query)
activity = result.scalars().one_or_none()
update_data = activity_in.model_dump(exclude_defaults=True) if not activity:
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 = activity_in.model_dump(exclude_defaults=True)
update_model(activity, update_data) update_model(activity, update_data)
await db_session.commit() await db_session.commit()
@ -52,7 +68,5 @@ async def update(
async def delete(*, db_session: DbSession, activity_id: str): async def delete(*, db_session: DbSession, activity_id: str):
"""Deletes a document.""" """Soft-deletes a document."""
activity = await db_session.get(MasterActivity, activity_id) await soft_delete_record(db_session=db_session, model=MasterActivity, record_id=activity_id)
await db_session.delete(activity)
await db_session.commit()

Loading…
Cancel
Save