219 lines
7.1 KiB
Python
219 lines
7.1 KiB
Python
"""
|
|
Transaction Marshmallow Schema
|
|
|
|
Validation and serialization schema for Transaction model.
|
|
Handles input validation, DECIMAL money precision, and JSON serialization.
|
|
"""
|
|
|
|
from marshmallow import Schema, fields, validate, validates, ValidationError, post_load
|
|
from decimal import Decimal, InvalidOperation
|
|
from datetime import date
|
|
|
|
|
|
class DecimalField(fields.Field):
|
|
"""Custom Decimal field for precise money handling."""
|
|
|
|
def _serialize(self, value, attr, obj, **kwargs):
|
|
"""Convert Decimal to float for JSON serialization."""
|
|
if value is None:
|
|
return None
|
|
return float(value)
|
|
|
|
def _deserialize(self, value, attr, data, **kwargs):
|
|
"""Convert input to Decimal with validation."""
|
|
if value is None:
|
|
return None
|
|
|
|
try:
|
|
# Convert to Decimal and validate precision
|
|
decimal_value = Decimal(str(value))
|
|
|
|
# Check for more than 2 decimal places
|
|
exponent = decimal_value.as_tuple().exponent
|
|
if isinstance(exponent, int) and exponent < -2:
|
|
raise ValidationError("Amount cannot have more than 2 decimal places")
|
|
|
|
# Check for reasonable range (max 99,999,999.99)
|
|
if decimal_value > Decimal('99999999.99'):
|
|
raise ValidationError("Amount cannot exceed $99,999,999.99")
|
|
|
|
if decimal_value <= 0:
|
|
raise ValidationError("Amount must be greater than 0")
|
|
|
|
return decimal_value
|
|
|
|
except (InvalidOperation, ValueError, TypeError):
|
|
raise ValidationError("Invalid amount format")
|
|
|
|
|
|
class TransactionSchema(Schema):
|
|
"""
|
|
Schema for Transaction model validation and serialization.
|
|
|
|
Provides input validation for:
|
|
- Amount: DECIMAL precision with 2 decimal places max
|
|
- Type: Enum validation (income/expense)
|
|
- Description: Required text field
|
|
- Category: Optional foreign key relationship
|
|
- Date: Transaction date validation
|
|
"""
|
|
|
|
# Fields with validation
|
|
id = fields.Integer(dump_only=True) # Read-only field
|
|
description = fields.String(
|
|
required=True,
|
|
validate=[
|
|
validate.Length(min=1, max=255, error="Description must be 1-255 characters"),
|
|
validate.Regexp(
|
|
r'^[a-zA-Z0-9\s\-_.,!@#$%^&*()+=\[\]{}|;:\'\"<>?/~`]+$',
|
|
error="Description contains invalid characters"
|
|
)
|
|
]
|
|
)
|
|
amount = DecimalField(required=True)
|
|
type = fields.String(
|
|
required=True,
|
|
validate=validate.OneOf(
|
|
['income', 'expense'],
|
|
error="Type must be either 'income' or 'expense'"
|
|
)
|
|
)
|
|
category_id = fields.Integer(
|
|
allow_none=True,
|
|
validate=validate.Range(min=1, error="Category ID must be a positive integer")
|
|
)
|
|
transaction_date = fields.Date(
|
|
required=True,
|
|
format='%Y-%m-%d',
|
|
error_messages={'invalid': 'Date must be in YYYY-MM-DD format'}
|
|
)
|
|
notes = fields.String(
|
|
allow_none=True,
|
|
validate=validate.Length(max=1000, error="Notes cannot exceed 1000 characters")
|
|
)
|
|
created_at = fields.DateTime(dump_only=True, format='iso')
|
|
|
|
# Nested category information for read operations
|
|
category_name = fields.String(dump_only=True)
|
|
|
|
@validates('transaction_date')
|
|
def validate_transaction_date(self, value, **kwargs):
|
|
"""Validate transaction date is not in the future."""
|
|
if value and value > date.today():
|
|
raise ValidationError("Transaction date cannot be in the future")
|
|
|
|
@post_load
|
|
def process_data(self, data, **kwargs):
|
|
"""Post-process validated data."""
|
|
# Set default transaction_date if not provided
|
|
if 'transaction_date' not in data:
|
|
data['transaction_date'] = date.today()
|
|
return data
|
|
|
|
class Meta:
|
|
"""Schema configuration."""
|
|
ordered = True # Preserve field order in output
|
|
|
|
|
|
class TransactionCreateSchema(TransactionSchema):
|
|
"""Schema for creating new transactions."""
|
|
|
|
class Meta:
|
|
"""Schema configuration for creation."""
|
|
exclude = ('id', 'created_at', 'category_name')
|
|
ordered = True
|
|
|
|
|
|
class TransactionUpdateSchema(TransactionSchema):
|
|
"""Schema for updating existing transactions."""
|
|
|
|
# Make all fields optional for partial updates
|
|
description = fields.String(
|
|
required=False,
|
|
validate=[
|
|
validate.Length(min=1, max=255, error="Description must be 1-255 characters"),
|
|
validate.Regexp(
|
|
r'^[a-zA-Z0-9\s\-_.,!@#$%^&*()+=\[\]{}|;:\'\"<>?/~`]+$',
|
|
error="Description contains invalid characters"
|
|
)
|
|
]
|
|
)
|
|
amount = DecimalField(required=False)
|
|
type = fields.String(
|
|
required=False,
|
|
validate=validate.OneOf(
|
|
['income', 'expense'],
|
|
error="Type must be either 'income' or 'expense'"
|
|
)
|
|
)
|
|
transaction_date = fields.Date(
|
|
required=False,
|
|
format='%Y-%m-%d',
|
|
error_messages={'invalid': 'Date must be in YYYY-MM-DD format'}
|
|
)
|
|
|
|
class Meta:
|
|
"""Schema configuration for updates."""
|
|
exclude = ('id', 'created_at', 'category_name')
|
|
ordered = True
|
|
|
|
|
|
class TransactionListSchema(Schema):
|
|
"""Schema for transaction list with category details."""
|
|
|
|
id = fields.Integer()
|
|
description = fields.String()
|
|
amount = DecimalField()
|
|
type = fields.String()
|
|
category_id = fields.Integer(allow_none=True)
|
|
category_name = fields.String(allow_none=True)
|
|
transaction_date = fields.Date(format='%Y-%m-%d')
|
|
notes = fields.String(allow_none=True)
|
|
created_at = fields.DateTime(format='iso')
|
|
|
|
class Meta:
|
|
"""Schema configuration."""
|
|
ordered = True
|
|
|
|
|
|
class TransactionFilterSchema(Schema):
|
|
"""Schema for transaction filtering parameters."""
|
|
|
|
type = fields.String(
|
|
validate=validate.OneOf(['income', 'expense']),
|
|
allow_none=True
|
|
)
|
|
category_id = fields.Integer(
|
|
validate=validate.Range(min=1),
|
|
allow_none=True
|
|
)
|
|
start_date = fields.Date(
|
|
format='%Y-%m-%d',
|
|
allow_none=True,
|
|
error_messages={'invalid': 'Start date must be in YYYY-MM-DD format'}
|
|
)
|
|
end_date = fields.Date(
|
|
format='%Y-%m-%d',
|
|
allow_none=True,
|
|
error_messages={'invalid': 'End date must be in YYYY-MM-DD format'}
|
|
)
|
|
page = fields.Integer(
|
|
validate=validate.Range(min=1),
|
|
load_default=1
|
|
)
|
|
per_page = fields.Integer(
|
|
validate=validate.Range(min=1, max=100),
|
|
load_default=20
|
|
)
|
|
|
|
@validates('end_date')
|
|
def validate_date_range(self, value):
|
|
"""Validate end_date is not before start_date."""
|
|
if value and 'start_date' in self.context:
|
|
start_date = self.context.get('start_date')
|
|
if start_date and value < start_date:
|
|
raise ValidationError("End date cannot be before start date")
|
|
|
|
class Meta:
|
|
"""Schema configuration."""
|
|
ordered = True
|