From 7cd2c837dfc0db3e1e13a797a524c28ccccea333 Mon Sep 17 00:00:00 2001 From: Cizz22 Date: Tue, 17 Dec 2024 13:12:51 +0700 Subject: [PATCH] update activities --- src/api.py | 10 ++ src/exceptions.py | 5 +- src/maximo/__init__.py | 0 src/maximo/service.py | 120 +++++++++++++++++++++++ src/overhaul_history/__init__.py | 0 src/overhaul_history/enums.py | 9 ++ src/overhaul_history/model.py | 17 ++++ src/overhaul_history/router.py | 51 ++++++++++ src/overhaul_history/schema.py | 33 +++++++ src/overhaul_history/service.py | 64 ++++++++++++ src/overhaul_history/utils.py | 20 ++++ src/scope_equipment_activity/__init__.py | 0 src/scope_equipment_activity/model.py | 18 ++++ src/scope_equipment_activity/router.py | 71 ++++++++++++++ src/scope_equipment_activity/schema.py | 69 +++++++++++++ src/scope_equipment_activity/service.py | 58 +++++++++++ src/utils.py | 80 +++++++++++++++ 17 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 src/maximo/__init__.py create mode 100644 src/maximo/service.py create mode 100644 src/overhaul_history/__init__.py create mode 100644 src/overhaul_history/enums.py create mode 100644 src/overhaul_history/model.py create mode 100644 src/overhaul_history/router.py create mode 100644 src/overhaul_history/schema.py create mode 100644 src/overhaul_history/service.py create mode 100644 src/overhaul_history/utils.py create mode 100644 src/scope_equipment_activity/__init__.py create mode 100644 src/scope_equipment_activity/model.py create mode 100644 src/scope_equipment_activity/router.py create mode 100644 src/scope_equipment_activity/schema.py create mode 100644 src/scope_equipment_activity/service.py create mode 100644 src/utils.py diff --git a/src/api.py b/src/api.py index 1458f8b..4eff9c8 100644 --- a/src/api.py +++ b/src/api.py @@ -11,6 +11,8 @@ from src.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.calculation_time_constrains.router import router as calculation_time_constrains_router +from src.overhaul_history.router import router as overhaul_history_router +from src.scope_equipment_activity.router import router as scope_equipment_activity_router class ErrorMessage(BaseModel): @@ -53,6 +55,14 @@ authenticated_api_router.include_router( scope_equipment_router, prefix="/scope-equipments", tags=["scope_equipment"] ) +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=["overhaul_history"] +) + # calculation calculation_router = APIRouter(prefix="/calculation", tags=["calculations"]) diff --git a/src/exceptions.py b/src/exceptions.py index 1694d00..63e1a9e 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -66,7 +66,7 @@ def handle_sqlalchemy_error(error: SQLAlchemyError): """ original_error = getattr(error, 'orig', None) print(original_error) - + if isinstance(error, IntegrityError): if "unique constraint" in str(error).lower(): return "This record already exists.", 409 @@ -147,13 +147,12 @@ def handle_exception(request: Request, exc: Exception): f"Unexpected Error | Error: {str(exc)} | Request: {request_info}", extra={"error_category": "unexpected"}, ) - return JSONResponse( status_code=500, content={ "data": None, - "message": exc.__class__.__name__, + "message": str(exc), "status": ResponseStatus.ERROR, "errors": [ ErrorDetail( diff --git a/src/maximo/__init__.py b/src/maximo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/maximo/service.py b/src/maximo/service.py new file mode 100644 index 0000000..9da804b --- /dev/null +++ b/src/maximo/service.py @@ -0,0 +1,120 @@ + + +from datetime import datetime +from typing import Any, Dict +from fastapi import HTTPException +import httpx +from starlette.config import Config +from src.config import config + + +class MaximoDataMapper: + """ + Helper class to map MAXIMO API response to our data structure. + Update these mappings according to actual MAXIMO API documentation. + """ + + def __init__(self, maximo_data: Dict[Any, Any]): + self.data = maximo_data + + def get_start_date(self) -> datetime: + """ + Extract start date from MAXIMO data. + TODO: Update this based on actual MAXIMO API response structure + Example: might be data['startDate'] or data['SCHEDSTART'] etc. + """ + # This is a placeholder - update with actual MAXIMO field name + start_date_str = self.data.get('scheduleStart') + if not start_date_str: + raise ValueError("Start date not found in MAXIMO data") + return datetime.fromisoformat(start_date_str) + + def get_end_date(self) -> datetime: + """ + Extract end date from MAXIMO data. + TODO: Update this based on actual MAXIMO API response structure + """ + # This is a placeholder - update with actual MAXIMO field name + end_date_str = self.data.get('scheduleEnd') + if not end_date_str: + raise ValueError("End date not found in MAXIMO data") + return datetime.fromisoformat(end_date_str) + + def get_maximo_id(self) -> str: + """ + Extract MAXIMO ID from response. + TODO: Update this based on actual MAXIMO API response structure + """ + # This is a placeholder - update with actual MAXIMO field name + maximo_id = self.data.get('workOrderId') + if not maximo_id: + raise ValueError("MAXIMO ID not found in response") + return str(maximo_id) + + def get_status(self) -> str: + """ + Extract status from MAXIMO data. + TODO: Update this based on actual MAXIMO API response structure + """ + # This is a placeholder - update with actual MAXIMO status field and values + status = self.data.get('status', '').upper() + return status + + def get_total_cost(self) -> float: + """ + Extract total cost from MAXIMO data. + TODO: Update this based on actual MAXIMO API response structure + """ + # This is a placeholder - update with actual MAXIMO field name + cost = self.data.get('totalCost', 0) + return float(cost) + + +class MaximoService: + def __init__(self): + # TODO: Update these settings based on actual MAXIMO API configuration + self.base_url = config.get("MAXIMO_BASE_URL") + self.api_key = config.get("MAXIMO_API_KEY") + + async def get_recent_overhaul(self) -> dict: + """ + Fetch most recent overhaul from MAXIMO. + TODO: Update this method based on actual MAXIMO API endpoints and parameters + """ + async with httpx.AsyncClient() as client: + try: + # TODO: Update endpoint and parameters based on actual MAXIMO API + response = await client.get( + f"{self.base_url}/your-endpoint-here", + headers={ + "Authorization": f"Bearer {self.api_key}", + # Add any other required headers + }, + params={ + # Update these parameters based on actual MAXIMO API + "orderBy": "-scheduleEnd", # Example parameter + "limit": 1 + } + ) + + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail=f"MAXIMO API error: {response.text}" + ) + + data = response.json() + if not data: + raise HTTPException( + status_code=404, + detail="No recent overhaul found" + ) + + # TODO: Update this based on actual MAXIMO response structure + return data[0] if isinstance(data, list) else data + + except httpx.RequestError as e: + raise HTTPException( + status_code=503, + detail=f"Failed to connect to MAXIMO: {str(e)}" + ) diff --git a/src/overhaul_history/__init__.py b/src/overhaul_history/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/overhaul_history/enums.py b/src/overhaul_history/enums.py new file mode 100644 index 0000000..6e3a338 --- /dev/null +++ b/src/overhaul_history/enums.py @@ -0,0 +1,9 @@ +from src.enums import OptimumOHEnum + +class OverhaulStatus(OptimumOHEnum): + PLANNED = "PLANNED" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + DELAYED = "DELAYED" + CANCELLED = "CANCELLED" + ON_HOLD = "ON_HOLD" \ No newline at end of file diff --git a/src/overhaul_history/model.py b/src/overhaul_history/model.py new file mode 100644 index 0000000..2103cd3 --- /dev/null +++ b/src/overhaul_history/model.py @@ -0,0 +1,17 @@ + +from sqlalchemy import UUID, Column, DateTime, Float, ForeignKey, Integer, String +from src.database.core import Base +from src.models import DefaultMixin +from .enums import OverhaulStatus + +class OverhaulHistory(Base, DefaultMixin): + __tablename__ = "oh_tr_overhaul_history" + + scope_id = Column(UUID(as_uuid=True), ForeignKey( + "oh_scope.id"), nullable=True) + schedule_start_date = Column(DateTime(timezone=True)) + schedule_end_date = Column(DateTime(timezone=True)) + total_cost = Column(Float, nullable=False, default=0) + status = Column(String, nullable=False, default=OverhaulStatus.PLANNED) + maximo_id = Column(String, nullable=True, + comment="Id From MAXIMO regarding overhaul schedule") diff --git a/src/overhaul_history/router.py b/src/overhaul_history/router.py new file mode 100644 index 0000000..74d064a --- /dev/null +++ b/src/overhaul_history/router.py @@ -0,0 +1,51 @@ + +from fastapi import APIRouter, HTTPException, status + +from src.maximo.service import MaximoService + +from .model import OverhaulHistory +from .schema import OverhaulHistoryCreate, OverhaulHistoryRead, OverhaulHistoryUpdate, OverhaulHistoryPagination +from .service import get, get_all, start_overhaul + +from src.database.service import CommonParameters, search_filter_sort_paginate +from src.database.core import DbSession +from src.auth.service import CurrentUser +from src.models import StandardResponse + +router = APIRouter() + + +@router.get("", response_model=StandardResponse[OverhaulHistoryPagination]) +async def get_histories(common: CommonParameters): + """Get all scope pagination.""" + # return + return StandardResponse( + data=await search_filter_sort_paginate(model=OverhaulHistory, **common), + message="Data retrieved successfully", + ) + + +@router.get("/{overhaul_history_id}", response_model=StandardResponse[OverhaulHistoryRead]) +async def get_history(db_session: DbSession, overhaul_history_id: str): + overhaul_history = await get(db_session=db_session, overhaul_history_id=overhaul_history_id) + if not overhaul_history: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="A data with this id does not exist.", + ) + + return StandardResponse(data=overhaul_history, message="Data retrieved successfully") + + +@router.post("", response_model=StandardResponse[OverhaulHistoryRead]) +async def create_history(db_session: DbSession, scope_in: OverhaulHistoryRead): + + try: + maximo_service = MaximoService() + maximo_data = await maximo_service.get_recent_overhaul() + overhaul = await start_overhaul(db_session=db_session, maximo_data=maximo_data) + + except HTTPException as he: + raise he + + return StandardResponse(data=overhaul, message="Data created successfully") diff --git a/src/overhaul_history/schema.py b/src/overhaul_history/schema.py new file mode 100644 index 0000000..c06900e --- /dev/null +++ b/src/overhaul_history/schema.py @@ -0,0 +1,33 @@ +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.scope.schema import ScopeRead + + +class OverhaulHistoryBase(DefultBase): + pass + + +class OverhaulHistoryCreate(OverhaulHistoryBase): + pass + + +class OverhaulHistoryUpdate(OverhaulHistoryBase): + pass + + +class OverhaulHistoryRead(OverhaulHistoryBase): + id: UUID + scope: ScopeRead + schedule_start_date: datetime + schedule_end_date: Optional[datetime] + total_cost: Optional[float] = Field(0) + maximo_id: Optional[str] + + +class OverhaulHistoryPagination(Pagination): + items: List[OverhaulHistoryRead] = [] diff --git a/src/overhaul_history/service.py b/src/overhaul_history/service.py new file mode 100644 index 0000000..b019d6a --- /dev/null +++ b/src/overhaul_history/service.py @@ -0,0 +1,64 @@ + + +from fastapi import HTTPException +from sqlalchemy import Select, Delete, and_ + +from src.maximo.service import MaximoDataMapper +from src.overhaul_history.enums import OverhaulStatus +from src.overhaul_history.utils import determine_overhaul_status +from .model import OverhaulHistory +from .schema import OverhaulHistoryRead, OverhaulHistoryCreate +from typing import Optional + +from src.database.core import DbSession +from src.auth.service import CurrentUser +from src.scope.service import get_by_scope_name + + +async def get(*, db_session: DbSession, overhaul_history_id: str) -> Optional[OverhaulHistory]: + """Returns a document based on the given document id.""" + result = await db_session.get(OverhaulHistory, overhaul_history_id) + return result.scalars().one_or_none() + + +async def get_all(*, db_session: DbSession): + """Returns all documents.""" + query = Select(OverhaulHistory) + result = await db_session.execute(query) + return result.scalars().all() + + +async def start_overhaul(*, db_session: DbSession, maximo_data: dict): + mapper = MaximoDataMapper(maximo_data) + maximo_id = mapper.get_maximo_id() + + # Check for existing overhaul + existing_overhaul = db_session.query(OverhaulHistory).filter( + and_( + OverhaulHistory.maximo_id == maximo_id, + OverhaulHistory.status == OverhaulStatus.IN_PROGRESS + ) + ).first() + + if existing_overhaul: + raise HTTPException( + status_code=409, + detail=f"Overhaul with MAXIMO ID {maximo_id} already started" + ) + + status, status_reason = await determine_overhaul_status(maximo_data) + scope = await get_by_scope_name("A") + + overhaul = OverhaulHistory( + scope_id=scope.id, + schedule_start_date=mapper.get_start_date(), + schedule_end_date=mapper.get_end_date(), + total_cost=mapper.get_total_cost(), + maximo_id=maximo_id, + status=status + ) + + db_session.add(overhaul) + await db_session.commit() + await db_session.refresh(overhaul) + return overhaul diff --git a/src/overhaul_history/utils.py b/src/overhaul_history/utils.py new file mode 100644 index 0000000..a4d7967 --- /dev/null +++ b/src/overhaul_history/utils.py @@ -0,0 +1,20 @@ +from typing import Any, Dict, Optional +from .enums import OverhaulStatus +from src.maximo.service import MaximoDataMapper + + +async def determine_overhaul_status(maximo_data: Dict[Any, Any]) -> tuple[str, Optional[str]]: + """Map MAXIMO status to our status enum""" + mapper = MaximoDataMapper(maximo_data) + maximo_status = mapper.get_status() + + # TODO: Update these mappings based on actual MAXIMO status values + status_mapping = { + 'COMP': OverhaulStatus.COMPLETED, + 'INPRG': OverhaulStatus.IN_PROGRESS, + 'PLAN': OverhaulStatus.PLANNED, + 'HOLD': OverhaulStatus.ON_HOLD, + # Add other status mappings based on actual MAXIMO statuses + } + + return status_mapping.get(maximo_status, OverhaulStatus.PLANNED), None diff --git a/src/scope_equipment_activity/__init__.py b/src/scope_equipment_activity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/scope_equipment_activity/model.py b/src/scope_equipment_activity/model.py new file mode 100644 index 0000000..6569dc5 --- /dev/null +++ b/src/scope_equipment_activity/model.py @@ -0,0 +1,18 @@ + +from sqlalchemy import UUID, Column, Float, Integer, String, ForeignKey +from src.database.core import Base +from src.models import DefaultMixin, IdentityMixin, TimeStampMixin +from sqlalchemy.orm import relationship +from src.workorder.model import MasterWorkOrder +from sqlalchemy.ext.hybrid import hybrid_property + + +class ScopeEquipmentActivity(Base, DefaultMixin): + __tablename__ = "oh_tr_overhaul_activity" + + assetnum = Column(String, nullable=True) + name = Column(String, nullable=False) + cost = Column(Float, nullable=False, default=0) + + scope_equipments = relationship( + "ScopeEquipment", lazy="raise", primaryjoin="and_(ScopeEquipmentActivity.assetnum == foreign(ScopeEquipment.assetnum))", uselist=False) diff --git a/src/scope_equipment_activity/router.py b/src/scope_equipment_activity/router.py new file mode 100644 index 0000000..05737fa --- /dev/null +++ b/src/scope_equipment_activity/router.py @@ -0,0 +1,71 @@ + +from fastapi import APIRouter, HTTPException, Query, status + + +from .service import get_all, create, get, update, delete +from .schema import ScopeEquipmentActivityCreate, ScopeEquipmentActivityPagination, ScopeEquipmentActivityRead, ScopeEquipmentActivityUpdate + +from src.models import StandardResponse +from src.database.service import CommonParameters, search_filter_sort_paginate, DbSession + +router = APIRouter() + + +@router.get("", response_model=StandardResponse[ScopeEquipmentActivityPagination]) +async def get_scope_equipment_activities(common: CommonParameters, assetnum: str = Query(None)): + """Get all scope activity pagination.""" + # return + data = await get_all(common=common, assetnum=assetnum) + + return StandardResponse( + data=data, + message="Data retrieved successfully", + ) + + +@router.post("", response_model=StandardResponse[ScopeEquipmentActivityRead]) +async def create_activity(db_session: DbSession, scope_equipment_activity_in: ScopeEquipmentActivityCreate): + + activity = await create(db_session=db_session, scope_equipment_activty_in=scope_equipment_activity_in) + + return StandardResponse(data=activity, message="Data created successfully") + + +@router.get("/{scope_equipment_activity_id}", response_model=StandardResponse[ScopeEquipmentActivityRead]) +async def get_activity(db_session: DbSession, scope_equipment_activity_id: str): + activity = await get(db_session=db_session, scope_equipment_activity_id=scope_equipment_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[ScopeEquipmentActivityRead]) +async def update_scope(db_session: DbSession, scope_equipment_activity_in: ScopeEquipmentActivityUpdate, scope_equipment_activity_id): + activity = await get(db_session=db_session, scope_equipment_activity_id=scope_equipment_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, scope_equipment_activity_in=scope_equipment_activity_in), message="Data updated successfully") + + +@router.delete("/{scope_equipment_activity_id}", response_model=StandardResponse[ScopeEquipmentActivityRead]) +async def delete_scope(db_session: DbSession, scope_equipment_activity_id: str): + activity = await get(db_session=db_session, scope_equipment_activity_id=scope_equipment_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, scope_equipment_activity_id=scope_equipment_activity_id) + + return StandardResponse(message="Data deleted successfully", data=activity) diff --git a/src/scope_equipment_activity/schema.py b/src/scope_equipment_activity/schema.py new file mode 100644 index 0000000..79ef18e --- /dev/null +++ b/src/scope_equipment_activity/schema.py @@ -0,0 +1,69 @@ + +from datetime import datetime +from typing import Any, Dict, List, Optional +from uuid import UUID + +from pydantic import Field, BaseModel +from src.models import DefultBase, Pagination + + +class ScopeEquipmentActivityBase(DefultBase): + assetnum: str = Field(..., description="Assetnum is required") + + +class ScopeEquipmentActivityCreate(ScopeEquipmentActivityBase): + name: str + cost: Optional[float] = Field(0) + + +class ScopeEquipmentActivityUpdate(ScopeEquipmentActivityBase): + name: Optional[str] = Field(None) + cost: Optional[str] = Field(0) + + +class ScopeEquipmentActivityRead(ScopeEquipmentActivityBase): + name: str + cost: float + + +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 +# } +# } diff --git a/src/scope_equipment_activity/service.py b/src/scope_equipment_activity/service.py new file mode 100644 index 0000000..cc5c8f5 --- /dev/null +++ b/src/scope_equipment_activity/service.py @@ -0,0 +1,58 @@ + + +from sqlalchemy import Select, Delete +from typing import Optional + +from .model import ScopeEquipmentActivity +from .schema import ScopeEquipmentActivityCreate, ScopeEquipmentActivityUpdate + +from src.database.core import DbSession +from src.database.service import CommonParameters, search_filter_sort_paginate +from src.auth.service import CurrentUser + + +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(common: CommonParameters, assetnum: Optional[str]): + query = Select(ScopeEquipmentActivity) + + if assetnum: + query = query.filter(ScopeEquipmentActivity.assetnum == assetnum) + + results = await search_filter_sort_paginate(model=query, **common) + + return results + + +async def create(*, db_session: DbSession, scope_equipment_activty_in: ScopeEquipmentActivityCreate): + activity = ScopeEquipmentActivity( + **scope_equipment_activty_in.model_dump()) + db_session.add(activity) + await db_session.commit() + return activity + + +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_equipment_activity_id: str): + """Deletes a document.""" + activity = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id) + await db_session.delete(activity) + await db_session.commit() diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..819fb61 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,80 @@ + + +from datetime import datetime, timedelta, timezone +import re +from typing import Optional +from dateutil.relativedelta import relativedelta + + +def parse_relative_expression(date_str: str) -> Optional[datetime]: + """ + Parse relative date expressions using T (days), M (months), and Y (years) + Returns tuple of (datetime, type_description) or None if not a relative date + """ + pattern = r"^([HTMY])([+-]\d+)?$" + match = re.match(pattern, date_str) + + if not match: + return None + + unit, offset = match.groups() + offset = int(offset) if offset else 0 + # Use UTC timezone for consistency + today = datetime.now(timezone.tzname("Asia/Jakarta")) + if unit == "H": + # For hours, keep minutes and seconds + result_time = today + timedelta(hours=offset) + return result_time + elif unit == "T": + return today + timedelta(days=offset) + elif unit == "M": + return today + relativedelta(months=offset) + elif unit == "Y": + return today + relativedelta(years=offset) + + +def parse_date_string(date_str: str) -> Optional[datetime]: + """ + Parse date strings in various formats including relative expressions + Returns tuple of (datetime, type) + """ + # Try parsing as relative expression first + relative_result = parse_relative_expression(date_str) + if relative_result: + return relative_result + + # Try different date formats + date_formats = [ + ("%Y-%m-%d", "iso"), # 2024-11-08 + ("%Y/%m/%d", "slash"), # 2024/11/08 + ("%d-%m-%Y", "european"), # 08-11-2024 + ("%d/%m/%Y", "european_slash"), # 08/11/2024 + ("%Y.%m.%d", "dot"), # 2024.11.08 + ("%d.%m.%Y", "european_dot"), # 08.11.2024 + ] + + for fmt, type_name in date_formats: + try: + # Parse the date and set it to start of day in UTC + dt = datetime.strptime(date_str, fmt) + dt = dt.replace( + hour=0, minute=0, second=0, microsecond=0, tzinfo=timezone.utc + ) + return dt + except ValueError: + continue + + raise ValueError( + "Invalid date format. Supported formats:\n" + "Relative formats:\n" + "- T (days): T, T-n, T+n\n" + "- M (months): M, M-1, M+2\n" + "- Y (years): Y, Y-1, Y+1\n" + "Regular formats:\n" + "- YYYY-MM-DD\n" + "- YYYY/MM/DD\n" + "- DD-MM-YYYY\n" + "- DD/MM/YYYY\n" + "- YYYY.MM.DD\n" + "- DD.MM.YYYY" + )