persona-finance-tracker-demo/app/api/categories.py
2025-09-13 17:40:23 +05:30

331 lines
10 KiB
Python

"""
Categories API Blueprint
This module contains API endpoints for category management.
Handles CRUD operations for transaction categories with full validation.
"""
from flask import Blueprint, request, jsonify
from marshmallow import ValidationError
from sqlalchemy.exc import IntegrityError
from typing import Dict, Any
from app.models import Category
from app.schemas import CategoryCreateSchema, CategoryUpdateSchema, CategoryListSchema
from app.extensions import db
# Create categories blueprint
categories_bp = Blueprint('categories', __name__)
# Initialize schemas
category_create_schema = CategoryCreateSchema()
category_update_schema = CategoryUpdateSchema()
category_list_schema = CategoryListSchema(many=True)
category_detail_schema = CategoryListSchema()
@categories_bp.route('/', methods=['GET'])
def get_categories():
"""
Get all categories with optional transaction counts.
Query Parameters:
- include_counts: boolean (default: false) - Include transaction counts
- active_only: boolean (default: true) - Show only active categories
Returns:
- 200: List of categories
"""
try:
# Parse query parameters
include_counts = request.args.get('include_counts', 'false').lower() == 'true'
active_only = request.args.get('active_only', 'true').lower() == 'true'
if include_counts:
# Use efficient query method to get categories with counts
categories_with_counts = Category.get_categories_with_counts()
# Filter by active status if requested
if active_only:
categories_with_counts = [
(cat, count) for cat, count in categories_with_counts
if cat.is_active
]
# Format response with counts
data = []
for category, count in categories_with_counts:
cat_dict = category_detail_schema.dump(category)
cat_dict['transaction_count'] = count
data.append(cat_dict)
else:
# Regular query without counts
if active_only:
categories = Category.get_active_categories()
else:
categories = Category.query.all()
data = category_list_schema.dump(categories)
return jsonify({
'success': True,
'data': data,
'count': len(data)
}), 200
except Exception as e:
return jsonify({
'success': False,
'error': 'internal_server_error',
'message': 'Failed to retrieve categories'
}), 500
@categories_bp.route('/', methods=['POST'])
def create_category():
"""
Create a new category.
Request Body:
- name: string (required) - Category name (unique)
- description: string (optional) - Category description
- color: string (optional) - Hex color code
- is_active: boolean (optional, default: true)
Returns:
- 201: Created category
- 400: Validation error
- 409: Category name already exists
"""
try:
# Validate and deserialize request data
json_data = request.get_json()
if not json_data:
return jsonify({
'success': False,
'error': 'validation_failed',
'message': 'No JSON data provided'
}), 400
validated_data = category_create_schema.load(json_data)
# Check if category name already exists
existing_category = Category.find_by_name(validated_data['name'])
if existing_category:
return jsonify({
'success': False,
'error': 'conflict',
'message': f'Category with name "{validated_data["name"]}" already exists'
}), 409
# Create new category
category = Category(**validated_data)
db.session.add(category)
db.session.commit()
# Return created category
return jsonify({
'success': True,
'data': category_detail_schema.dump(category),
'message': 'Category created successfully'
}), 201
except ValidationError as e:
return jsonify({
'success': False,
'error': 'validation_failed',
'message': 'Input validation failed',
'details': e.messages
}), 400
except IntegrityError:
db.session.rollback()
return jsonify({
'success': False,
'error': 'conflict',
'message': 'Category name must be unique'
}), 409
except Exception as e:
db.session.rollback()
return jsonify({
'success': False,
'error': 'internal_server_error',
'message': 'Failed to create category'
}), 500
@categories_bp.route('/<int:category_id>', methods=['GET'])
def get_category(category_id):
"""
Get a specific category by ID.
Path Parameters:
- category_id: integer - Category ID
Returns:
- 200: Category details
- 404: Category not found
"""
try:
category = Category.query.get(category_id)
if not category:
return jsonify({
'success': False,
'error': 'not_found',
'message': f'Category with ID {category_id} not found'
}), 404
# Get transaction count for this category
transaction_count = category.get_transaction_count()
data = category_detail_schema.dump(category)
data['transaction_count'] = transaction_count
return jsonify({
'success': True,
'data': data
}), 200
except Exception as e:
return jsonify({
'success': False,
'error': 'internal_server_error',
'message': 'Failed to retrieve category'
}), 500
@categories_bp.route('/<int:category_id>', methods=['PUT'])
def update_category(category_id):
"""
Update a specific category.
Path Parameters:
- category_id: integer - Category ID
Request Body:
- name: string (optional) - Category name
- description: string (optional) - Category description
- color: string (optional) - Hex color code
- is_active: boolean (optional)
Returns:
- 200: Updated category
- 400: Validation error
- 404: Category not found
- 409: Category name already exists
"""
try:
category = Category.query.get(category_id)
if not category:
return jsonify({
'success': False,
'error': 'not_found',
'message': f'Category with ID {category_id} not found'
}), 404
# Validate and deserialize request data
json_data = request.get_json()
if not json_data:
return jsonify({
'success': False,
'error': 'validation_failed',
'message': 'No JSON data provided'
}), 400
validated_data = category_update_schema.load(json_data)
# Check if name is being changed and if new name already exists
if 'name' in validated_data and validated_data['name'] != category.name:
existing_category = Category.find_by_name(validated_data['name'])
if existing_category:
return jsonify({
'success': False,
'error': 'conflict',
'message': f'Category with name "{validated_data["name"]}" already exists'
}), 409
# Update category fields
for field, value in validated_data.items():
setattr(category, field, value)
db.session.commit()
return jsonify({
'success': True,
'data': category_detail_schema.dump(category),
'message': 'Category updated successfully'
}), 200
except ValidationError as e:
return jsonify({
'success': False,
'error': 'validation_failed',
'message': 'Input validation failed',
'details': e.messages
}), 400
except IntegrityError:
db.session.rollback()
return jsonify({
'success': False,
'error': 'conflict',
'message': 'Category name must be unique'
}), 409
except Exception as e:
db.session.rollback()
return jsonify({
'success': False,
'error': 'internal_server_error',
'message': 'Failed to update category'
}), 500
@categories_bp.route('/<int:category_id>', methods=['DELETE'])
def delete_category(category_id):
"""
Soft delete a category (set is_active = False).
Path Parameters:
- category_id: integer - Category ID
Returns:
- 200: Category deleted successfully
- 404: Category not found
- 409: Cannot delete category with transactions
"""
try:
category = Category.query.get(category_id)
if not category:
return jsonify({
'success': False,
'error': 'not_found',
'message': f'Category with ID {category_id} not found'
}), 404
# Check if category has active transactions
transaction_count = category.get_transaction_count()
if transaction_count > 0:
return jsonify({
'success': False,
'error': 'conflict',
'message': f'Cannot delete category with {transaction_count} transactions. Please reassign or delete transactions first.'
}), 409
# Soft delete by setting is_active to False
category.is_active = False
db.session.commit()
return jsonify({
'success': True,
'message': 'Category deleted successfully'
}), 200
except Exception as e:
db.session.rollback()
return jsonify({
'success': False,
'error': 'internal_server_error',
'message': 'Failed to delete category'
}), 500