"""FileUpload marshmallow schemas.""" import re from typing import Any, Dict from abacus_common_logic.marshalling.custom_fields import ma from marshmallow import ValidationError, validates_schema from abacus_file_upload.constants import ( S3_MAX_FILE_SIZE_BYTES, UPLOAD_STATUSES, UPLOAD_TYPES, ) from abacus_file_upload.utils import format_bytes # Validation helper functions def validate_filename(filename: str) -> None: """Validate filename format and characters. Args: filename: Original filename Raises: ValidationError: If filename is invalid or contains illegal characters """ if not filename or not filename.strip(): raise ValidationError('filename cannot be empty', field_name='filename') # Check for invalid characters that could cause issues invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|', '\x00'] for char in invalid_chars: if char in filename: raise ValidationError( f'filename contains invalid character: {repr(char)}', field_name='filename', ) def validate_file_size(file_size: int) -> None: """Validate file size is within acceptable limits. Args: file_size: File size in bytes Raises: ValidationError: If file size is invalid or exceeds S3 limits """ if file_size <= 0: raise ValidationError( 'file_size_bytes must be greater than 0', field_name='file_size_bytes' ) if file_size > S3_MAX_FILE_SIZE_BYTES: max_size = format_bytes(S3_MAX_FILE_SIZE_BYTES) raise ValidationError( f'file_size_bytes must be <= {S3_MAX_FILE_SIZE_BYTES} bytes ({max_size})', field_name='file_size_bytes', ) def validate_md5(value: str) -> None: """Validate MD5 hash format (32 hexadecimal characters). Args: value: MD5 hash string to validate Raises: ValidationError: If MD5 format is invalid """ if not value: raise ValidationError('MD5 hash is required', field_name='md5sum') if not re.match(r'^[a-fA-F0-9]{32}$', value): raise ValidationError( 'MD5 hash must be 32 hexadecimal characters', field_name='md5sum' ) class FileUploadDetailSchema(ma.Schema): """FileUpload detail response schema.""" file_upload_id = ma.IntegerId( metadata={ 'description': 'Unique identifier for the file upload', 'example': 12345, } ) file_upload_config_id = ma.IntegerId( metadata={'description': 'ID of the upload configuration used', 'example': 1} ) file_key = ma.NonemptyString( metadata={ 'description': 'Unique key identifying this file upload', 'example': 'a2c96ff7-e7a0-42fd-aad1-1b8436940d35', } ) original_file_name = ma.NonemptyString( metadata={ 'description': 'Original filename provided by the user', 'example': 'Q4_2024_adjustments.xlsx', } ) file_size_bytes = ma.Integer( metadata={ 'description': 'Size of the file in bytes', 'example': 10485760, } # 10MB ) file_type = ma.String( allow_none=True, metadata={'description': 'Type of file upload', 'example': 'adjustments'}, ) mime_type = ma.String( allow_none=True, metadata={ 'description': 'MIME type of the uploaded file', 'example': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }, ) s3_bucket = ma.NonemptyString( metadata={ 'description': 'S3 bucket where the file is stored', 'example': 'qa-abacus-adjustments', } ) s3_key = ma.NonemptyString( metadata={ 'description': 'S3 object key path', 'example': 'uploads/2025/11/a2c96ff7-e7a0-42fd-aad1-1b8436940d35.xlsx', } ) md5sum = ma.String( allow_none=True, metadata={ 'description': 'MD5 hash of the file contents', 'example': '414d79a12f1be626734b3b6696b52723', }, ) upload_status = ma.Enum( options=UPLOAD_STATUSES, metadata={ 'description': 'Current status of the upload (e.g., init, completed)', 'example': 'completed', }, ) multipart_upload_id = ma.String( allow_none=True, metadata={ 'description': 'S3 multipart upload ID for large files', 'example': 'exampleMultipartUploadId123', }, ) total_parts = ma.Integer( metadata={ 'description': 'Total number of parts for multipart uploads', 'example': 4, } ) upload_metadata = ma.Dict( allow_none=True, metadata={ 'description': 'Optional custom JSON object to associate with the upload', 'example': {'user_id': '12345', 'team': 'abacus'}, }, ) error_message = ma.String( allow_none=True, metadata={'description': 'Error message if upload failed', 'example': None}, ) completed_at = ma.FormattedDateTime( allow_none=True, metadata={ 'description': 'Timestamp when upload was completed', 'example': '2025-11-24T04:35:43.396Z', }, ) expires_at = ma.FormattedDateTime( allow_none=True, metadata={ 'description': 'Timestamp when upload URLs expire', 'example': '2025-11-24T05:35:43.396Z', }, ) class FileUploadDetailVerboseSchema(FileUploadDetailSchema): """FileUpload detail response schema with audit fields.""" created_at = ma.FormattedDateTime( metadata={ 'description': 'Timestamp when the record was created', 'example': '2025-11-24T04:35:43.396Z', } ) created_by = ma.String( metadata={ 'description': 'User identity ID of the user who created the record', 'example': '12345', } ) last_modified = ma.FormattedDateTime( metadata={ 'description': 'Timestamp when the record was last modified', 'example': '2025-11-24T04:36:15.123Z', } ) last_modified_by = ma.String( metadata={ 'description': 'User identity ID of the user who last modified the record', 'example': '12345', } ) deleted_at = ma.FormattedDateTime( allow_none=True, metadata={ 'description': 'Timestamp when the record was deleted (soft delete)', 'example': None, }, ) deleted_by = ma.String( allow_none=True, metadata={ 'description': 'User identity ID of the user who deleted the record', 'example': '67890', }, ) class InitiateUploadRequestSchema(ma.Schema): """Schema for initiate upload request.""" upload_type = ma.Enum( options=UPLOAD_TYPES, required=True, metadata={'description': 'Type of file upload', 'example': 'adjustments'}, ) filename = ma.NonemptyString( required=True, metadata={ 'description': 'Original filename to be uploaded', 'example': 'Q4_2024_adjustments.xlsx', }, ) file_size_bytes = ma.Integer( required=True, metadata={ 'description': 'Size of the file in bytes', 'example': 10485760, # 10MB }, ) mime_type = ma.String( allow_none=True, metadata={ 'description': 'MIME type of the file', 'example': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }, ) md5sum = ma.NonemptyString( required=True, metadata={ 'description': 'MD5 hash of the file contents (32 hexadecimal characters)', 'example': '414d79a12f1be626734b3b6696b52723', }, ) metadata = ma.Dict( allow_none=True, metadata={ 'description': 'Optional custom JSON object to associate with the upload', 'example': {'user_id': '12345', 'team': 'abacus'}, }, ) @validates_schema def validate_data(self, data: Dict[str, Any], **kwargs): """Validate upload request data. Args: data: Request data to validate **kwargs: Additional keyword arguments from marshmallow Raises: ValidationError: If validation fails """ # Validate file size if 'file_size_bytes' in data: validate_file_size(data['file_size_bytes']) # Validate MD5 if 'md5sum' in data: validate_md5(data['md5sum']) # Validate filename if 'filename' in data: validate_filename(data['filename']) class MultipartPartSchema(ma.Schema): """Schema for a single multipart upload part.""" part_number = ma.Integer( required=True, metadata={ 'description': 'Part number for this upload chunk (1-indexed)', 'example': 1, }, ) url = ma.String( required=True, metadata={ 'description': 'Pre-signed S3 URL for uploading this part', 'example': 'https://s3.amazonaws.com/bucket/key?partNumber=1&uploadId=xyz&X-Amz-Signature=...', }, ) expires_at = ma.String( required=True, metadata={ 'description': 'ISO format datetime when the URL expires', 'example': '2025-11-24T05:35:43.396Z', }, ) class InitiateUploadResponseSchema(ma.Schema): """Schema for initiate upload response.""" file_key = ma.NonemptyString( metadata={ 'description': 'Unique key identifying this file upload', 'example': 'a2c96ff7-e7a0-42fd-aad1-1b8436940d35', } ) is_multipart = ma.Boolean( metadata={ 'description': 'Whether this upload uses multipart upload (for large files)', 'example': True, } ) expires_at = ma.String( metadata={ 'description': 'ISO format datetime when the upload URLs expire', 'example': '2025-11-24T05:35:43.396Z', } ) chunk_size_bytes = ma.Integer( allow_none=True, metadata={ 'description': 'Size of each chunk for multipart uploads (in bytes)', 'example': 5242880, }, ) upload_url = ma.String( allow_none=True, metadata={ 'description': 'Pre-signed S3 URL for single-part uploads (null for multipart)', 'example': None, }, ) complete_url = ma.String( allow_none=True, metadata={ 'description': 'URL to call after completing upload (multipart uploads only)', 'example': 'https://api.example.com/file-upload/a2c96ff7-e7a0-42fd-aad1-1b8436940d35/complete', }, ) parts = ma.List( ma.Nested(MultipartPartSchema), allow_none=True, metadata={ 'description': 'List of pre-signed URLs for each part (multipart uploads only)', 'example': [ { 'part_number': 1, 'url': 'https://s3.amazonaws.com/bucket/key?partNumber=1&uploadId=xyz', 'expires_at': '2025-11-24T05:35:43.396Z', }, { 'part_number': 2, 'url': 'https://s3.amazonaws.com/bucket/key?partNumber=2&uploadId=xyz', 'expires_at': '2025-11-24T05:35:43.396Z', }, ], }, ) required_headers = ma.Dict( allow_none=True, metadata={ 'description': 'HTTP headers that must be included in upload requests', 'example': {'Content-MD5': '414d79a12f1be626734b3b6696b52723'}, }, ) class FileUploadStatusPutSchema(ma.Schema): """FileUploadStatus PUT request schema.""" upload_status = ma.Enum( options=UPLOAD_STATUSES, metadata={ 'description': 'Current status of the upload (e.g., init, completed)', 'example': 'completed', }, )