From 2615ff9a6ed02c16ab6074a7d7e14fc8f44a3ab7 Mon Sep 17 00:00:00 2001 From: TAMDeveloper13 Date: Thu, 2 Apr 2026 02:43:25 +0000 Subject: [PATCH] Update: Sheet 8 & 11 compliance + changing api router due to error unauthorized when access some pages --- src/api.py | 2 +- src/equipment_workscope_group/service.py | 2 + src/main.py | 15 +++- src/middleware.py | 64 +++++++++++++++ src/models.py | 9 ++- src/overhaul_activity/service.py | 33 +++++++- src/overhaul_job/service.py | 14 ++-- src/overhaul_schedule/service.py | 14 ++-- src/overhaul_scope/router.py | 12 +-- src/overhaul_scope/service.py | 41 +++++++--- src/soft_delete.py | 77 +++++++++++++++++++ src/sparepart/service.py | 10 ++- src/standard_scope/service.py | 39 +++++++--- src/workscope_group/router.py | 10 +-- src/workscope_group/service.py | 34 +++++--- .../service.py | 34 +++++--- 16 files changed, 326 insertions(+), 84 deletions(-) create mode 100644 src/soft_delete.py diff --git a/src/api.py b/src/api.py index 9ab4aca..0741a14 100644 --- a/src/api.py +++ b/src/api.py @@ -174,7 +174,7 @@ calculation_router.include_router( authenticated_api_router.include_router(calculation_router) -api_router.include_router( +authenticated_api_router.include_router( get_calculation, prefix="/calculation/time-constraint", tags=["calculation", "time_constraint"], diff --git a/src/equipment_workscope_group/service.py b/src/equipment_workscope_group/service.py index 44469b2..192e940 100644 --- a/src/equipment_workscope_group/service.py +++ b/src/equipment_workscope_group/service.py @@ -17,6 +17,7 @@ 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.""" @@ -58,6 +59,7 @@ async def get_all(*, common, location_tag: str, scope: Optional[str] = None): 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 = ( diff --git a/src/main.py b/src/main.py index 2972868..f450184 100644 --- a/src/main.py +++ b/src/main.py @@ -1,4 +1,5 @@ import logging +import asyncio import time from src.context import set_request_id, reset_request_id, get_request_id 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.exceptions import handle_exception 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.rate_limiter import limiter from fastapi.exceptions import RequestValidationError @@ -40,11 +41,13 @@ log = logging.getLogger(__name__) # we configure the logging level and format configure_logging() +REQUEST_TIMEOUT_SECONDS = 20 + # we create the ASGI for the app app = FastAPI( openapi_url="", - title="LCCA API", - description="Welcome to LCCA's API documentation!", + title="Optimum OH API", + description="Welcome to Optimum OH's API documentation!", version="0.1.0", ) @@ -58,10 +61,16 @@ app.add_exception_handler(RateLimitExceeded, handle_exception) 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"]) +async def slow_endpoint(request: Request): + await asyncio.sleep(REQUEST_TIMEOUT_SECONDS + 10) + return JSONResponse(content={"message": "This should timeout"}, status_code=200) + diff --git a/src/middleware.py b/src/middleware.py index 90992e6..0fc7ea8 100644 --- a/src/middleware.py +++ b/src/middleware.py @@ -424,3 +424,67 @@ class RequestValidationMiddleware(BaseHTTPMiddleware): 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") + + +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, + }) \ No newline at end of file diff --git a/src/models.py b/src/models.py index 36df72e..34c0118 100644 --- a/src/models.py +++ b/src/models.py @@ -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""" pass diff --git a/src/overhaul_activity/service.py b/src/overhaul_activity/service.py index a7b9c99..dac2569 100644 --- a/src/overhaul_activity/service.py +++ b/src/overhaul_activity/service.py @@ -3,6 +3,7 @@ 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 @@ -30,6 +31,7 @@ from .schema import (OverhaulActivityCreate, OverhaulActivityRead, 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 @@ -40,6 +42,7 @@ async def get( .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) @@ -105,6 +108,7 @@ async def get_all( (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) @@ -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(selectinload(OverhaulActivity.overhaul_scope)) ) + query = apply_not_deleted_filter(query, OverhaulActivity) 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( *, db_session: DbSession, - activity: OverhaulActivity, + activity_id: str, + overhaul_session_id: Optional[UUID] = None, overhaul_activity_in: OverhaulActivityUpdate ): - """Updates a document.""" - data = overhaul_activity_in.model_dump() + """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() + ) + 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) await db_session.commit() diff --git a/src/overhaul_job/service.py b/src/overhaul_job/service.py index c30da08..dc739ba 100644 --- a/src/overhaul_job/service.py +++ b/src/overhaul_job/service.py @@ -13,8 +13,9 @@ from src.database.service import search_filter_sort_paginate 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 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): @@ -23,6 +24,7 @@ async def get_all(*, common, location_tag: str, scope: Optional[str] = None): Select(EquipmentWorkscopeGroup) .where(EquipmentWorkscopeGroup.location_tag == location_tag) ) + query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup) if scope: query = ( @@ -84,18 +86,14 @@ async def delete( AuthorizationError: If user lacks delete permission """ try: - # Check if job exists - scope_job = await db_session.get(OverhaulJob, overhaul_job_id) - if not scope_job: + # 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.", ) - # Perform deletion - await db_session.delete(scope_job) - await db_session.commit() - return True except Exception as e: diff --git a/src/overhaul_schedule/service.py b/src/overhaul_schedule/service.py index 1c69675..086fff5 100644 --- a/src/overhaul_schedule/service.py +++ b/src/overhaul_schedule/service.py @@ -10,6 +10,7 @@ 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 @@ -18,6 +19,7 @@ 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) results = await search_filter_sort_paginate(model=query, **common) return results @@ -38,7 +40,11 @@ async def create( 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) + 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) @@ -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): - """Deletes a document.""" - query = Delete(OverhaulSchedule).where(OverhaulSchedule.id == overhaul_schedule_id) - await db_session.execute(query) - await db_session.commit() + """Soft-deletes a document.""" + await soft_delete_record(db_session=db_session, model=OverhaulSchedule, record_id=overhaul_schedule_id) diff --git a/src/overhaul_scope/router.py b/src/overhaul_scope/router.py index 2eb5c75..0687e62 100644 --- a/src/overhaul_scope/router.py +++ b/src/overhaul_scope/router.py @@ -58,23 +58,15 @@ async def update_scope( scope_in: ScopeUpdate, 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( - 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", ) @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): - 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: raise HTTPException( diff --git a/src/overhaul_scope/service.py b/src/overhaul_scope/service.py index f24f861..4bfe017 100644 --- a/src/overhaul_scope/service.py +++ b/src/overhaul_scope/service.py @@ -18,20 +18,21 @@ from .schema import ScopeCreate, ScopeUpdate from .utils import get_material_cost, get_service_cost 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, for_update: bool = False + *, db_session: DbSession, overhaul_session_id: UUID ) -> Optional[OverhaulScope]: """Returns a document based on the given document id.""" query = Select(OverhaulScope).filter(OverhaulScope.id == overhaul_session_id).options(selectinload(OverhaulScope.maintenance_type)) - if for_update: - query = query.with_for_update() + 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() @@ -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): """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) @@ -136,12 +138,27 @@ async def create(*, db_session: DbSession, scope_in: ScopeCreate): return overhaul_session -async def update(*, db_session: DbSession, scope: OverhaulScope, scope_in: ScopeUpdate): - """Updates a document.""" - data = scope_in.model_dump() +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 = 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) 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): - """Deletes a document.""" - query = Delete(OverhaulScope).where(OverhaulScope.id == scope_id) - await db_session.execute(query) - await db_session.commit() + """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): @@ -168,6 +183,7 @@ async def get_overview_overhaul(*, db_session: DbSession): 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() @@ -180,6 +196,7 @@ async def get_overview_overhaul(*, db_session: DbSession): .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() @@ -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)) ).distinct() ) + equipments = apply_not_deleted_filter(equipments, StandardScope) 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): 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) return results.scalars().all() \ No newline at end of file diff --git a/src/soft_delete.py b/src/soft_delete.py new file mode 100644 index 0000000..6bf9ab8 --- /dev/null +++ b/src/soft_delete.py @@ -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}") diff --git a/src/sparepart/service.py b/src/sparepart/service.py index 399d473..23f0fbe 100644 --- a/src/sparepart/service.py +++ b/src/sparepart/service.py @@ -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.sparepart.model import SparepartRemark from src.sparepart.schema import ProcurementRecord, ProcurementStatus, SparepartRequirement, SparepartStock +from src.soft_delete import apply_not_deleted_filter log = logging.getLogger(__name__) @@ -380,7 +381,7 @@ async def get_spareparts_paginated(*, db_session, collector_db_session): ) sparepart_remark = (await db_session.execute( - select(SparepartRemark) + apply_not_deleted_filter(select(SparepartRemark), SparepartRemark) )).scalars().all() 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_remark = (await app_db_session.execute( - select(SparepartRemark) + apply_not_deleted_filter(select(SparepartRemark), SparepartRemark) )).scalars().all() 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): # Step 1: Check if remark already exists for this itemnum 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() diff --git a/src/standard_scope/service.py b/src/standard_scope/service.py index f0779f6..8261307 100644 --- a/src/standard_scope/service.py +++ b/src/standard_scope/service.py @@ -13,6 +13,7 @@ from src.database.service import CommonParameters, search_filter_sort_paginate from src.overhaul_scope.model import OverhaulScope from src.standard_scope.enum import ScopeEquipmentType 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.equipment_workscope_group.model import EquipmentWorkscopeGroup 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) .options(selectinload(StandardScope.master_equipment)) ) + query = apply_not_deleted_filter(query, StandardScope) result = await db_session.execute(query) 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( selectinload(StandardScope.master_equipment) ) + query = apply_not_deleted_filter(query, StandardScope) 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)) ).distinct() ) + query = apply_not_deleted_filter(query, StandardScope) result = await db_session.execute(query) return result.scalars().all(), overhaul @@ -147,14 +151,29 @@ async def create(*, db_session: DbSession, scope_equipment_in: ScopeEquipmentCre async def update( *, db_session: DbSession, - scope_equipment: StandardScope, + location_tag: str, scope_equipment_in: ScopeEquipmentUpdate ): - """Updates a document.""" - data = scope_equipment_in.model_dump() + """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update.""" + 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) await db_session.commit() @@ -163,11 +182,11 @@ async def update( async def delete(*, db_session: DbSession, assetnum: str): - """Deletes a document.""" - query = Delete(StandardScope).where(StandardScope.assetnum == assetnum) - await db_session.execute(query) - await db_session.commit() - + """Soft-deletes a document.""" + result = await get_by_location_tag(db_session=db_session, location_tag=assetnum) + if result: + 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 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 = apply_not_deleted_filter(query, MasterEquipment) # Only add not_in filter if there are items in 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) .where(MasterEquipmentTree.level_no == level) ) + query = apply_not_deleted_filter(query, MasterEquipment) result = await db_session.execute(query) return result.scalars().all() diff --git a/src/workscope_group/router.py b/src/workscope_group/router.py index dc734a6..ff0e1ff 100644 --- a/src/workscope_group/router.py +++ b/src/workscope_group/router.py @@ -53,17 +53,9 @@ async def get_activity(db_session: DbSession, activity_id: str): 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 + db_session=db_session, activity_id=activity_id, activity_in=activity_in ), message="Data updated successfully", ) diff --git a/src/workscope_group/service.py b/src/workscope_group/service.py index 35085d8..15ec94b 100644 --- a/src/workscope_group/service.py +++ b/src/workscope_group/service.py @@ -1,5 +1,6 @@ from typing import Optional +from fastapi import HTTPException, status from sqlalchemy import Delete, Select 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.service import CommonParameters, search_filter_sort_paginate from src.utils import update_model +from src.soft_delete import apply_not_deleted_filter, soft_delete_record from .model import MasterActivity 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]: """Returns a document based on the given document id.""" - result = await db_session.get(MasterActivity, activity_id) - return result + query = Select(MasterActivity).filter(MasterActivity.id == activity_id) + 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): query = Select(MasterActivity) + query = apply_not_deleted_filter(query, MasterActivity) 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( *, db_session: DbSession, - activity: MasterActivity, + activity_id: str, activity_in: ActivityMasterCreate ): - """Updates a document.""" - data = activity_in.model_dump() + """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update.""" + 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) await db_session.commit() @@ -52,7 +68,5 @@ async def update( async def delete(*, db_session: DbSession, activity_id: str): - """Deletes a document.""" - activity = await db_session.get(MasterActivity, activity_id) - await db_session.delete(activity) - await db_session.commit() + """Soft-deletes a document.""" + await soft_delete_record(db_session=db_session, model=MasterActivity, record_id=activity_id) diff --git a/src/workscope_group_maintenance_type/service.py b/src/workscope_group_maintenance_type/service.py index 35085d8..15ec94b 100644 --- a/src/workscope_group_maintenance_type/service.py +++ b/src/workscope_group_maintenance_type/service.py @@ -1,5 +1,6 @@ from typing import Optional +from fastapi import HTTPException, status from sqlalchemy import Delete, Select 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.service import CommonParameters, search_filter_sort_paginate from src.utils import update_model +from src.soft_delete import apply_not_deleted_filter, soft_delete_record from .model import MasterActivity 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]: """Returns a document based on the given document id.""" - result = await db_session.get(MasterActivity, activity_id) - return result + query = Select(MasterActivity).filter(MasterActivity.id == activity_id) + 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): query = Select(MasterActivity) + query = apply_not_deleted_filter(query, MasterActivity) 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( *, db_session: DbSession, - activity: MasterActivity, + activity_id: str, activity_in: ActivityMasterCreate ): - """Updates a document.""" - data = activity_in.model_dump() + """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update.""" + 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) await db_session.commit() @@ -52,7 +68,5 @@ async def update( async def delete(*, db_session: DbSession, activity_id: str): - """Deletes a document.""" - activity = await db_session.get(MasterActivity, activity_id) - await db_session.delete(activity) - await db_session.commit() + """Soft-deletes a document.""" + await soft_delete_record(db_session=db_session, model=MasterActivity, record_id=activity_id)