The models/dtos subpackage contains Pydantic BaseModel data transfer objects used for API request/response shapes,
pagination, sorting, search, and email payloads.
classBaseDTO(BaseModel):"""Base Data Transfer Object class. This class extends Pydantic's BaseModel to provide common configuration for all DTOs in the application. """model_config=ConfigDict(extra="ignore",validate_default=True,from_attributes=True,frozen=True,str_strip_whitespace=True,arbitrary_types_allowed=True,)
classBaseProtobufDTO(BaseDTO):"""A base DTO that can be converted to and from a Protobuf message. Requires 'google-protobuf' to be installed. """_proto_class:ClassVar[type[Message]|None]=Nonedef__init__(self,*args:Any,**kwargs:Any)->None:# Add a check at runtime when someone tries to use the classifnotPROTOBUF_AVAILABLE:raiseRuntimeError("The 'protobuf' extra is not installed. ")super().__init__(*args,**kwargs)@classmethoddeffrom_proto(cls,request:Message)->Self:"""Converts a Protobuf message into a Pydantic DTO instance."""ifcls._proto_classisNone:raiseNotImplementedError(f"{cls.__name__} is not mapped to a proto class.")ifnotisinstance(request,cls._proto_class):raiseInvalidEntityTypeError(message=f"{cls.__name__}.from_proto expected a different type of request.",expected_type=cls._proto_class.__name__,actual_type=type(request).__name__,)input_data=MessageToDict(message=request,always_print_fields_with_no_presence=True,preserving_proto_field_name=True,)returncls.model_validate(input_data)defto_proto(self)->Message:"""Converts the Pydantic DTO instance into a Protobuf message."""ifself._proto_classisNone:raiseNotImplementedError(f"{self.__class__.__name__} is not mapped to a proto class.")returnParseDict(self.model_dump(mode="json"),self._proto_class())
@classmethoddeffrom_proto(cls,request:Message)->Self:"""Converts a Protobuf message into a Pydantic DTO instance."""ifcls._proto_classisNone:raiseNotImplementedError(f"{cls.__name__} is not mapped to a proto class.")ifnotisinstance(request,cls._proto_class):raiseInvalidEntityTypeError(message=f"{cls.__name__}.from_proto expected a different type of request.",expected_type=cls._proto_class.__name__,actual_type=type(request).__name__,)input_data=MessageToDict(message=request,always_print_fields_with_no_presence=True,preserving_proto_field_name=True,)returncls.model_validate(input_data)
defto_proto(self)->Message:"""Converts the Pydantic DTO instance into a Protobuf message."""ifself._proto_classisNone:raiseNotImplementedError(f"{self.__class__.__name__} is not mapped to a proto class.")returnParseDict(self.model_dump(mode="json"),self._proto_class())
This DTO encapsulates pagination information for database queries and API responses,
providing a standard way to specify which subset of results to retrieve.
classPaginationDTO(BaseDTO):"""Data Transfer Object for pagination parameters. This DTO encapsulates pagination information for database queries and API responses, providing a standard way to specify which subset of results to retrieve. Attributes: page (int): The current page number (1-based indexing) page_size (int): Number of items per page offset (int): Calculated offset for database queries based on page and page_size Examples: >>> from archipy.models.dtos.pagination_dto import PaginationDTO >>> >>> # Default pagination (page 1, 10 items per page) >>> pagination = PaginationDTO() >>> >>> # Custom pagination >>> pagination = PaginationDTO(page=2, page_size=25) >>> print(pagination.offset) # Access offset as a property 25 >>> >>> # Using with a database query >>> def get_users(pagination: PaginationDTO): ... query = select(User).offset(pagination.offset).limit(pagination.page_size) ... return db.execute(query).scalars().all() """page:int=Field(default=1,ge=1,description="Page number (1-indexed)")page_size:int=Field(default=10,ge=1,le=100,description="Number of items per page")MAX_ITEMS:ClassVar=10000@model_validator(mode="after")defvalidate_pagination(self)->Self:"""Validate pagination limits to prevent excessive resource usage. Ensures that the requested number of items (page * page_size) doesn't exceed the maximum allowed limit. Returns: The validated model instance if valid. Raises: OutOfRangeError: If the total requested items exceeds MAX_ITEMS. """total_items=self.page*self.page_sizeiftotal_items>self.MAX_ITEMS:raiseOutOfRangeError(field_name="pagination")returnself@propertydefoffset(self)->int:"""Calculate the offset for database queries. This property calculates how many records to skip based on the current page and page size. Returns: int: The number of records to skip Examples: >>> pagination = PaginationDTO(page=3, page_size=20) >>> pagination.offset 40 # Skip the first 40 records (2 pages of 20 items) """return(self.page-1)*self.page_size
@model_validator(mode="after")defvalidate_pagination(self)->Self:"""Validate pagination limits to prevent excessive resource usage. Ensures that the requested number of items (page * page_size) doesn't exceed the maximum allowed limit. Returns: The validated model instance if valid. Raises: OutOfRangeError: If the total requested items exceeds MAX_ITEMS. """total_items=self.page*self.page_sizeiftotal_items>self.MAX_ITEMS:raiseOutOfRangeError(field_name="pagination")returnself
This DTO encapsulates sorting information for database queries and API responses,
providing a standard way to specify how results should be ordered.
Attributes:
Name
Type
Description
column
T | str
The name or enum value of the column to sort by
order
str
The sort direction - "ASC" for ascending, "DESC" for descending
Examples:
>>> fromarchipy.models.dtos.sort_dtoimportSortDTO>>> fromarchipy.models.types.sort_order_typeimportSortOrderType>>>>>> # Sort by name in ascending order>>> sort=SortDTO(column="name",order=SortOrderType.ASCENDING)>>>>>> # Sort by creation date in descending order (newest first)>>> sort=SortDTO(column="created_at",order="DESCENDING")>>>>>> # Using with a database query>>> defget_sorted_users(sort:SortDTO=SortDTO.default()):... query=select(User)... ifsort.order==SortOrderType.ASCENDING:... query=query.order_by(getattr(User,sort.column).asc())... else:... query=query.order_by(getattr(User,sort.column).desc())... returndb.execute(query).scalars().all()>>>>>> # Using with enum column types>>> fromenumimportEnum>>> classUserColumns(Enum):... ID="id"... NAME="name"... EMAIL="email"... CREATED_AT="created_at">>>>>> # Create a sort configuration with enum>>> sort=SortDTO[UserColumns](column=UserColumns.NAME,order=SortOrderType.ASCENDING)
classSortDTO[T](BaseModel):"""Data Transfer Object for sorting parameters. This DTO encapsulates sorting information for database queries and API responses, providing a standard way to specify how results should be ordered. Attributes: column (T | str): The name or enum value of the column to sort by order (str): The sort direction - "ASC" for ascending, "DESC" for descending Examples: >>> from archipy.models.dtos.sort_dto import SortDTO >>> from archipy.models.types.sort_order_type import SortOrderType >>> >>> # Sort by name in ascending order >>> sort = SortDTO(column="name", order=SortOrderType.ASCENDING) >>> >>> # Sort by creation date in descending order (newest first) >>> sort = SortDTO(column="created_at", order="DESCENDING") >>> >>> # Using with a database query >>> def get_sorted_users(sort: SortDTO = SortDTO.default()): ... query = select(User) ... if sort.order == SortOrderType.ASCENDING: ... query = query.order_by(getattr(User, sort.column).asc()) ... else: ... query = query.order_by(getattr(User, sort.column).desc()) ... return db.execute(query).scalars().all() >>> >>> # Using with enum column types >>> from enum import Enum >>> class UserColumns(Enum): ... ID = "id" ... NAME = "name" ... EMAIL = "email" ... CREATED_AT = "created_at" >>> >>> # Create a sort configuration with enum >>> sort = SortDTO[UserColumns](column=UserColumns.NAME, order=SortOrderType.ASCENDING) """column:T|str=Field(default="created_at",description="Column name or enum to sort by")order:SortOrderType=Field(default=SortOrderType.DESCENDING,description="Sort order (ASCENDING or DESCENDING)")@classmethoddefdefault(cls)->SortDTO:"""Create a default sort configuration. Returns a sort configuration that orders by created_at in descending order (newest first), which is a common default sorting behavior. Returns: SortDTO: A default sort configuration Examples: >>> default_sort = SortDTO.default() >>> print(f"Sort by {default_sort.column} {default_sort.order}") Sort by created_at DESCENDING """returncls(column="created_at",order=SortOrderType.DESCENDING)
@classmethoddefdefault(cls)->SortDTO:"""Create a default sort configuration. Returns a sort configuration that orders by created_at in descending order (newest first), which is a common default sorting behavior. Returns: SortDTO: A default sort configuration Examples: >>> default_sort = SortDTO.default() >>> print(f"Sort by {default_sort.column} {default_sort.order}") Sort by created_at DESCENDING """returncls(column="created_at",order=SortOrderType.DESCENDING)
classSearchInputDTO[T](BaseModel):"""Data Transfer Object for search inputs with pagination and sorting. This DTO encapsulates search parameters for database queries and API responses, providing a standard way to handle pagination and sorting. Type Parameters: T: The type for sort column (usually an Enum with column names). """pagination:PaginationDTO|None=Nonesort_info:SortDTO[T]|None=None
classComparable(Protocol):"""Protocol for types that support comparison operators."""def__gt__(self,other:object)->bool:"""Greater than comparison operator."""...
classBaseRangeDTO[R](BaseDTO):"""Base Data Transfer Object for range queries. Encapsulates a range of values with from_ and to fields. Provides validation to ensure range integrity. """from_:R|None=Noneto:R|None=None@model_validator(mode="after")defvalidate_range(self)->Self:"""Validate that from_ is less than or equal to to when both are provided. Returns: Self: The validated model instance. Raises: OutOfRangeError: If from_ is greater than to. """ifself.from_isnotNoneandself.toisnotNone:# Use comparison with proper type handling# The protocol ensures both values support comparisontry:ifself.from_>self.to:# ty: ignore[unsupported-operator]raiseOutOfRangeError(field_name="from_")exceptTypeError:# If comparison fails, skip validation (shouldn't happen with proper types)passreturnself
@model_validator(mode="after")defvalidate_range(self)->Self:"""Validate that from_ is less than or equal to to when both are provided. Returns: Self: The validated model instance. Raises: OutOfRangeError: If from_ is greater than to. """ifself.from_isnotNoneandself.toisnotNone:# Use comparison with proper type handling# The protocol ensures both values support comparisontry:ifself.from_>self.to:# ty: ignore[unsupported-operator]raiseOutOfRangeError(field_name="from_")exceptTypeError:# If comparison fails, skip validation (shouldn't happen with proper types)passreturnself
classDecimalRangeDTO(BaseRangeDTO[Decimal]):"""Data Transfer Object for decimal range queries."""from_:Decimal|None=Noneto:Decimal|None=None@field_validator("from_","to",mode="before")@classmethoddefconvert_to_decimal(cls,value:Decimal|str|None)->Decimal|None:"""Convert input values to Decimal type. Args: value: The value to convert (None, string, or Decimal). Returns: Decimal | None: The converted Decimal value or None. Raises: InvalidArgumentError: If the value cannot be converted to Decimal. """ifvalueisNone:returnNonetry:returnDecimal(value)except(TypeError,ValueError)ase:raiseInvalidArgumentError(argument_name="value")frome
@field_validator("from_","to",mode="before")@classmethoddefconvert_to_decimal(cls,value:Decimal|str|None)->Decimal|None:"""Convert input values to Decimal type. Args: value: The value to convert (None, string, or Decimal). Returns: Decimal | None: The converted Decimal value or None. Raises: InvalidArgumentError: If the value cannot be converted to Decimal. """ifvalueisNone:returnNonetry:returnDecimal(value)except(TypeError,ValueError)ase:raiseInvalidArgumentError(argument_name="value")frome
classDatetimeIntervalRangeDTO(BaseRangeDTO[datetime]):"""Data Transfer Object for datetime range queries with interval. Rejects requests if the number of intervals exceeds MAX_ITEMS or if interval-specific range size or 'to' age constraints are violated. """from_:datetimeto:datetimeinterval:TimeIntervalUnitType# Maximum number of intervals allowedMAX_ITEMS:ClassVar[int]=100# Range size limits for each intervalRANGE_SIZE_LIMITS:ClassVar[dict[TimeIntervalUnitType,timedelta]]={TimeIntervalUnitType.SECONDS:timedelta(days=2),TimeIntervalUnitType.MINUTES:timedelta(days=7),TimeIntervalUnitType.HOURS:timedelta(days=30),TimeIntervalUnitType.DAYS:timedelta(days=365),TimeIntervalUnitType.WEEKS:timedelta(days=365*2),TimeIntervalUnitType.MONTHS:timedelta(days=365*5),# No limit for MONTHS, set highTimeIntervalUnitType.YEAR:timedelta(days=365*10),# No limit for YEAR, set high}# 'to' age limits for each intervalTO_AGE_LIMITS:ClassVar[dict[TimeIntervalUnitType,timedelta]]={TimeIntervalUnitType.SECONDS:timedelta(days=2),TimeIntervalUnitType.MINUTES:timedelta(days=7),TimeIntervalUnitType.HOURS:timedelta(days=30),TimeIntervalUnitType.DAYS:timedelta(days=365*5),TimeIntervalUnitType.WEEKS:timedelta(days=365*10),TimeIntervalUnitType.MONTHS:timedelta(days=365*20),# No limit for MONTHS, set highTimeIntervalUnitType.YEAR:timedelta(days=365*50),# No limit for YEAR, set high}# Mapping of intervals to timedelta for step sizeINTERVAL_TO_TIMEDELTA:ClassVar[dict[TimeIntervalUnitType,timedelta]]={TimeIntervalUnitType.SECONDS:timedelta(seconds=1),TimeIntervalUnitType.MINUTES:timedelta(minutes=1),TimeIntervalUnitType.HOURS:timedelta(hours=1),TimeIntervalUnitType.DAYS:timedelta(days=1),TimeIntervalUnitType.WEEKS:timedelta(weeks=1),TimeIntervalUnitType.MONTHS:timedelta(days=30),# ApproximateTimeIntervalUnitType.YEAR:timedelta(days=365),# Approximate}@model_validator(mode="after")defvalidate_interval_constraints(self)->Self:"""Validate interval based on range size, 'to' field age, and max intervals. - Each interval has specific range size and 'to' age limits. - Rejects if the number of intervals exceeds MAX_ITEMS. Returns: Self: The validated model instance. Raises: OutOfRangeError: If interval constraints are violated or number of intervals > MAX_ITEMS. """ifself.from_isnotNoneandself.toisnotNone:# Validate range size limit for the selected intervalrange_size=self.to-self.from_max_range_size=self.RANGE_SIZE_LIMITS.get(self.interval)ifmax_range_sizeandrange_size>max_range_size:raiseOutOfRangeError(field_name="range_size")# Validate 'to' age limitcurrent_time=datetime.now()max_to_age=self.TO_AGE_LIMITS.get(self.interval)ifmax_to_age:age_threshold=current_time-max_to_ageifself.to<age_threshold:raiseOutOfRangeError(field_name="to")# Calculate number of intervalsstep=self.INTERVAL_TO_TIMEDELTA[self.interval]range_duration=self.to-self.from_num_intervals=int(range_duration.total_seconds()/step.total_seconds())+1# Reject if number of intervals exceeds MAX_ITEMSifnum_intervals>self.MAX_ITEMS:raiseOutOfRangeError(field_name="interval_count")returnself
@model_validator(mode="after")defvalidate_interval_constraints(self)->Self:"""Validate interval based on range size, 'to' field age, and max intervals. - Each interval has specific range size and 'to' age limits. - Rejects if the number of intervals exceeds MAX_ITEMS. Returns: Self: The validated model instance. Raises: OutOfRangeError: If interval constraints are violated or number of intervals > MAX_ITEMS. """ifself.from_isnotNoneandself.toisnotNone:# Validate range size limit for the selected intervalrange_size=self.to-self.from_max_range_size=self.RANGE_SIZE_LIMITS.get(self.interval)ifmax_range_sizeandrange_size>max_range_size:raiseOutOfRangeError(field_name="range_size")# Validate 'to' age limitcurrent_time=datetime.now()max_to_age=self.TO_AGE_LIMITS.get(self.interval)ifmax_to_age:age_threshold=current_time-max_to_ageifself.to<age_threshold:raiseOutOfRangeError(field_name="to")# Calculate number of intervalsstep=self.INTERVAL_TO_TIMEDELTA[self.interval]range_duration=self.to-self.from_num_intervals=int(range_duration.total_seconds()/step.total_seconds())+1# Reject if number of intervals exceeds MAX_ITEMSifnum_intervals>self.MAX_ITEMS:raiseOutOfRangeError(field_name="interval_count")returnself
classEmailAttachmentDTO(BaseDTO):"""Pydantic model for email attachments."""content:str|bytes|BinaryIOfilename:strcontent_type:str|None=Field(default=None)content_disposition:EmailAttachmentDispositionType=Field(default=EmailAttachmentDispositionType.ATTACHMENT)content_id:str|None=Field(default=None)attachment_type:EmailAttachmentTypemax_size:int@model_validator(mode="after")defvalidate_attachment(self)->Self:"""Validate and normalize attachment fields. This validator performs three operations: 1. Sets content_type based on filename extension if not provided 2. Validates that attachment size does not exceed maximum allowed size 3. Ensures content_id is properly formatted with angle brackets Returns: The validated model instance Raises: ValueError: If attachment size exceeds maximum allowed size """# Set content type from filename if not providedifself.content_typeisNone:content_type,_=mimetypes.guess_type(self.filename)self.content_type=content_typeor"application/octet-stream"# Validate attachment sizecontent=self.contentifisinstance(content,str|bytes):content_size=len(content)ifcontent_size>self.max_size:error_msg=f"Attachment size exceeds maximum allowed size of {self.max_size} bytes"raiseValueError(error_msg)# Ensure content_id has angle bracketsifself.content_idandnotself.content_id.startswith("<"):self.content_id=f"<{self.content_id}>"returnself
This validator performs three operations:
1. Sets content_type based on filename extension if not provided
2. Validates that attachment size does not exceed maximum allowed size
3. Ensures content_id is properly formatted with angle brackets
@model_validator(mode="after")defvalidate_attachment(self)->Self:"""Validate and normalize attachment fields. This validator performs three operations: 1. Sets content_type based on filename extension if not provided 2. Validates that attachment size does not exceed maximum allowed size 3. Ensures content_id is properly formatted with angle brackets Returns: The validated model instance Raises: ValueError: If attachment size exceeds maximum allowed size """# Set content type from filename if not providedifself.content_typeisNone:content_type,_=mimetypes.guess_type(self.filename)self.content_type=content_typeor"application/octet-stream"# Validate attachment sizecontent=self.contentifisinstance(content,str|bytes):content_size=len(content)ifcontent_size>self.max_size:error_msg=f"Attachment size exceeds maximum allowed size of {self.max_size} bytes"raiseValueError(error_msg)# Ensure content_id has angle bracketsifself.content_idandnotself.content_id.startswith("<"):self.content_id=f"<{self.content_id}>"returnself
classFastAPIErrorResponseDTO:"""Standardized error response model for OpenAPI documentation."""def__init__(self,exception:type[BaseError],additional_properties:dict|None=None)->None:"""Initialize the error response model. Args: exception: The exception class (not instance) with error details as class attributes additional_properties: Additional properties to include in the response """self.status_code=exception.http_status# Base properties that all errors havedetail_properties={"code":{"type":"string","example":exception.code,"description":"Error code identifier"},"message_en":{"type":"string","example":exception.message_en,"description":"Error message in English",},"message_fa":{"type":"string","example":exception.message_fa,"description":"Error message in Persian",},"http_status":{"type":"integer","example":exception.http_status,"description":"HTTP status code"},}# Add additional properties if providedifadditional_properties:detail_properties.update(additional_properties)self.model={"description":exception.message_en,"content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":exception.code,"description":"Error code identifier",},"detail":{"type":"object","properties":detail_properties,"required":["code","message_en","message_fa","http_status"],"additionalProperties":False,"description":"Detailed error information",},},},},},}
classValidationErrorResponseDTO(FastAPIErrorResponseDTO):"""Specific response model for validation errors."""def__init__(self)->None:"""Initialize the validation error response model."""self.status_code=HTTPStatus.UNPROCESSABLE_ENTITYself.model={"description":"Validation Error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"VALIDATION_ERROR","description":"Error code identifier",},"detail":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string","example":"email","description":"Field name that failed validation",},"message":{"type":"string","example":"Invalid email format","description":"Validation error message",},"value":{"type":"string","example":"invalid@email","description":"Invalid value that caused the error",},},},"example":[{"field":"email","message":"Invalid email format","value":"invalid@email"},{"field":"password","message":"Password must be at least 8 characters","value":"123",},],},},},},},}