Initial commit
This commit is contained in:
commit
542a633603
30 changed files with 3776 additions and 0 deletions
17
.env.example
Normal file
17
.env.example
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Personal Finance API Environment Configuration
|
||||
# Copy this file to .env and update values for your environment
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=development
|
||||
FLASK_DEBUG=True
|
||||
FLASK_HOST=127.0.0.1
|
||||
FLASK_PORT=5000
|
||||
SECRET_KEY=your-secret-key-here
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://username:password@localhost/personal_finance_dev
|
||||
DEV_DATABASE_URL=postgresql://username:password@localhost/personal_finance_dev
|
||||
TEST_DATABASE_URL=sqlite:///:memory:
|
||||
|
||||
# Development Settings
|
||||
SQLALCHEMY_ECHO=True
|
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
.env
|
1
.python-version
Normal file
1
.python-version
Normal file
|
@ -0,0 +1 @@
|
|||
3.12
|
0
README.md
Normal file
0
README.md
Normal file
108
app/__init__.py
Normal file
108
app/__init__.py
Normal file
|
@ -0,0 +1,108 @@
|
|||
"""
|
||||
Personal Finance API - Application Factory
|
||||
|
||||
This module implements the Flask application factory pattern following Flask 3.0 best practices.
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_cors import CORS
|
||||
|
||||
from app.extensions import db, migrate
|
||||
from app.config import get_config
|
||||
|
||||
|
||||
def create_app(config_name=None):
|
||||
"""
|
||||
Application factory function that creates and configures a Flask application instance.
|
||||
|
||||
Args:
|
||||
config_name (str): Configuration name ('development', 'testing', 'production')
|
||||
If None, uses FLASK_ENV environment variable or defaults to 'development'
|
||||
|
||||
Returns:
|
||||
Flask: Configured Flask application instance
|
||||
"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# Load configuration
|
||||
if config_name is None:
|
||||
config_name = os.getenv('FLASK_ENV', 'development')
|
||||
|
||||
config = get_config(config_name)
|
||||
app.config.from_object(config)
|
||||
|
||||
# Initialize CORS for frontend integration
|
||||
CORS(app, origins=['*']) # Allow all origins for development
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
# Import models so they are registered with SQLAlchemy
|
||||
from app.models import Category, Transaction
|
||||
|
||||
# Register blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
# Register error handlers
|
||||
register_error_handlers(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register Flask blueprints with the application."""
|
||||
from app.api.categories import categories_bp
|
||||
from app.api.transactions import transactions_bp
|
||||
from app.api.analytics import analytics_bp
|
||||
|
||||
# Register API blueprints with proper URL prefixes
|
||||
app.register_blueprint(categories_bp, url_prefix='/api/v1/categories')
|
||||
app.register_blueprint(transactions_bp, url_prefix='/api/v1/transactions')
|
||||
app.register_blueprint(analytics_bp, url_prefix='/api/v1/analytics')
|
||||
|
||||
|
||||
def register_error_handlers(app):
|
||||
"""Register custom error handlers for the application."""
|
||||
from flask import jsonify
|
||||
from marshmallow import ValidationError
|
||||
|
||||
@app.errorhandler(ValidationError)
|
||||
def handle_validation_error(e):
|
||||
"""Handle Marshmallow validation errors."""
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Input validation failed',
|
||||
'details': e.messages
|
||||
}), 400
|
||||
|
||||
@app.errorhandler(404)
|
||||
def handle_not_found(e):
|
||||
"""Handle 404 errors."""
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'not_found',
|
||||
'message': 'Resource not found'
|
||||
}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def handle_internal_error(e):
|
||||
"""Handle 500 errors."""
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'internal_server_error',
|
||||
'message': 'An internal server error occurred'
|
||||
}), 500
|
||||
|
||||
@app.route('/health')
|
||||
def health_check():
|
||||
"""Health check endpoint for monitoring."""
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'status': 'healthy',
|
||||
'message': 'Personal Finance API is running'
|
||||
})
|
13
app/api/__init__.py
Normal file
13
app/api/__init__.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
"""
|
||||
API Blueprints
|
||||
|
||||
This module contains Flask blueprints for API endpoints.
|
||||
Each major feature area has its own blueprint for clean organization.
|
||||
"""
|
||||
|
||||
from .categories import categories_bp
|
||||
from .transactions import transactions_bp
|
||||
from .analytics import analytics_bp
|
||||
|
||||
# Export all blueprints
|
||||
__all__ = ['categories_bp', 'transactions_bp', 'analytics_bp']
|
202
app/api/analytics.py
Normal file
202
app/api/analytics.py
Normal file
|
@ -0,0 +1,202 @@
|
|||
"""
|
||||
Analytics API Blueprint
|
||||
|
||||
This module contains API endpoints for financial analytics and reporting.
|
||||
Provides summary statistics and spending analysis.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, date
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from app.models import Transaction, Category
|
||||
from app.extensions import db
|
||||
|
||||
# Create analytics blueprint
|
||||
analytics_bp = Blueprint('analytics', __name__)
|
||||
|
||||
|
||||
@analytics_bp.route('/summary', methods=['GET'])
|
||||
def get_summary():
|
||||
"""
|
||||
Get financial summary analytics.
|
||||
|
||||
Query Parameters:
|
||||
- start_date: string (YYYY-MM-DD) - Filter from date (optional)
|
||||
- end_date: string (YYYY-MM-DD) - Filter to date (optional)
|
||||
|
||||
Returns:
|
||||
- 200: Summary statistics including total income, expenses, and balance
|
||||
"""
|
||||
try:
|
||||
# Parse optional date filters
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
|
||||
# Base query
|
||||
query = Transaction.query
|
||||
|
||||
# Apply date filters if provided
|
||||
if start_date:
|
||||
try:
|
||||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Transaction.transaction_date >= start_date_obj)
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Invalid start_date format. Use YYYY-MM-DD'
|
||||
}), 400
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Transaction.transaction_date <= end_date_obj)
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Invalid end_date format. Use YYYY-MM-DD'
|
||||
}), 400
|
||||
|
||||
# Calculate summary statistics
|
||||
total_income = query.filter(Transaction.type == 'income').with_entities(
|
||||
func.coalesce(func.sum(Transaction.amount), 0)
|
||||
).scalar() or 0
|
||||
|
||||
total_expenses = query.filter(Transaction.type == 'expense').with_entities(
|
||||
func.coalesce(func.sum(Transaction.amount), 0)
|
||||
).scalar() or 0
|
||||
|
||||
transaction_count = query.count()
|
||||
|
||||
# Calculate net balance (income - expenses)
|
||||
net_balance = float(total_income) - float(total_expenses)
|
||||
|
||||
summary_data = {
|
||||
'total_income': float(total_income),
|
||||
'total_expenses': float(total_expenses),
|
||||
'net_balance': net_balance,
|
||||
'transaction_count': transaction_count
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': summary_data,
|
||||
'message': 'Financial summary calculated successfully'
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'internal_server_error',
|
||||
'message': 'Failed to retrieve analytics summary'
|
||||
}), 500
|
||||
|
||||
|
||||
@analytics_bp.route('/spending-by-category', methods=['GET'])
|
||||
def get_spending_by_category():
|
||||
"""
|
||||
Get spending breakdown by category.
|
||||
|
||||
Query Parameters:
|
||||
- start_date: string (YYYY-MM-DD) - Filter from date (optional)
|
||||
- end_date: string (YYYY-MM-DD) - Filter to date (optional)
|
||||
- type: string (income/expense) - Filter by transaction type (default: expense)
|
||||
|
||||
Returns:
|
||||
- 200: Spending breakdown by category
|
||||
"""
|
||||
try:
|
||||
# Parse optional filters
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
transaction_type = request.args.get('type', 'expense')
|
||||
|
||||
# Validate transaction type
|
||||
if transaction_type not in ['income', 'expense']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Type must be either "income" or "expense"'
|
||||
}), 400
|
||||
|
||||
# Base query - join with Category for category names
|
||||
query = db.session.query(
|
||||
Category.id,
|
||||
Category.name,
|
||||
Category.color,
|
||||
func.sum(Transaction.amount).label('total_amount'),
|
||||
func.count(Transaction.id).label('transaction_count')
|
||||
).outerjoin(
|
||||
Transaction,
|
||||
(Category.id == Transaction.category_id) &
|
||||
(Transaction.type == transaction_type)
|
||||
).group_by(Category.id, Category.name, Category.color)
|
||||
|
||||
# Apply date filters if provided
|
||||
if start_date:
|
||||
try:
|
||||
start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Transaction.transaction_date >= start_date_obj)
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Invalid start_date format. Use YYYY-MM-DD'
|
||||
}), 400
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date()
|
||||
query = query.filter(Transaction.transaction_date <= end_date_obj)
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Invalid end_date format. Use YYYY-MM-DD'
|
||||
}), 400
|
||||
|
||||
# Execute query and format results
|
||||
results = query.all()
|
||||
|
||||
# Calculate total for percentage calculation
|
||||
total_amount = sum(float(result.total_amount or 0) for result in results)
|
||||
|
||||
category_data = []
|
||||
for result in results:
|
||||
amount = float(result.total_amount or 0)
|
||||
percentage = (amount / total_amount * 100) if total_amount > 0 else 0
|
||||
|
||||
# Only include categories that have transactions
|
||||
if amount > 0:
|
||||
category_data.append({
|
||||
'category_id': result.id,
|
||||
'category_name': result.name,
|
||||
'category_color': result.color,
|
||||
'total_amount': amount,
|
||||
'transaction_count': result.transaction_count,
|
||||
'percentage': round(percentage, 2)
|
||||
})
|
||||
|
||||
# Sort by total amount descending
|
||||
category_data.sort(key=lambda x: x['total_amount'], reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': category_data,
|
||||
'summary': {
|
||||
'total_amount': total_amount,
|
||||
'category_count': len(category_data),
|
||||
'transaction_type': transaction_type
|
||||
},
|
||||
'message': f'{transaction_type.title()} breakdown by category calculated successfully'
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'internal_server_error',
|
||||
'message': 'Failed to retrieve spending by category'
|
||||
}), 500
|
331
app/api/categories.py
Normal file
331
app/api/categories.py
Normal file
|
@ -0,0 +1,331 @@
|
|||
"""
|
||||
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
|
364
app/api/transactions.py
Normal file
364
app/api/transactions.py
Normal file
|
@ -0,0 +1,364 @@
|
|||
"""
|
||||
Transactions API Blueprint
|
||||
|
||||
This module contains API endpoints for transaction management.
|
||||
Handles CRUD operations for financial transactions with validation and filtering.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from datetime import datetime, date
|
||||
|
||||
from app.models import Transaction, Category
|
||||
from app.schemas import (
|
||||
TransactionCreateSchema, TransactionUpdateSchema,
|
||||
TransactionListSchema, TransactionFilterSchema
|
||||
)
|
||||
from app.extensions import db
|
||||
|
||||
# Create transactions blueprint
|
||||
transactions_bp = Blueprint('transactions', __name__)
|
||||
|
||||
# Initialize schemas
|
||||
transaction_create_schema = TransactionCreateSchema()
|
||||
transaction_update_schema = TransactionUpdateSchema()
|
||||
transaction_list_schema = TransactionListSchema(many=True)
|
||||
transaction_detail_schema = TransactionListSchema()
|
||||
transaction_filter_schema = TransactionFilterSchema()
|
||||
|
||||
|
||||
@transactions_bp.route('/', methods=['GET'])
|
||||
def get_transactions():
|
||||
"""
|
||||
Get all transactions with optional filtering.
|
||||
|
||||
Query Parameters:
|
||||
- type: string (income/expense) - Filter by transaction type
|
||||
- category_id: integer - Filter by category ID
|
||||
- start_date: string (YYYY-MM-DD) - Filter from date
|
||||
- end_date: string (YYYY-MM-DD) - Filter to date
|
||||
- page: integer (default: 1) - Page number for pagination
|
||||
- per_page: integer (default: 20, max: 100) - Items per page
|
||||
|
||||
Returns:
|
||||
- 200: List of transactions with pagination info
|
||||
"""
|
||||
try:
|
||||
# Validate filter parameters
|
||||
filter_data = transaction_filter_schema.load(request.args.to_dict())
|
||||
|
||||
# Start with base query
|
||||
query = Transaction.query
|
||||
|
||||
# Apply filters
|
||||
if filter_data.get('type'):
|
||||
query = query.filter(Transaction.type == filter_data['type'])
|
||||
|
||||
if filter_data.get('category_id'):
|
||||
query = query.filter(Transaction.category_id == filter_data['category_id'])
|
||||
|
||||
if filter_data.get('start_date'):
|
||||
query = query.filter(Transaction.transaction_date >= filter_data['start_date'])
|
||||
|
||||
if filter_data.get('end_date'):
|
||||
query = query.filter(Transaction.transaction_date <= filter_data['end_date'])
|
||||
|
||||
# Order by transaction_date descending, then by created_at descending
|
||||
query = query.order_by(Transaction.transaction_date.desc(), Transaction.created_at.desc())
|
||||
|
||||
# Pagination
|
||||
page = filter_data['page']
|
||||
per_page = filter_data['per_page']
|
||||
|
||||
paginated = query.paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
# Serialize transactions with category names
|
||||
transactions_data = []
|
||||
for transaction in paginated.items:
|
||||
data = transaction_detail_schema.dump(transaction)
|
||||
if transaction.category:
|
||||
data['category_name'] = transaction.category.name
|
||||
transactions_data.append(data)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': transactions_data,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': paginated.total,
|
||||
'pages': paginated.pages,
|
||||
'has_next': paginated.has_next,
|
||||
'has_prev': paginated.has_prev
|
||||
}
|
||||
}), 200
|
||||
|
||||
except ValidationError as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Filter validation failed',
|
||||
'details': e.messages
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'internal_server_error',
|
||||
'message': 'Failed to retrieve transactions'
|
||||
}), 500
|
||||
|
||||
|
||||
@transactions_bp.route('/', methods=['POST'])
|
||||
def create_transaction():
|
||||
"""
|
||||
Create a new transaction.
|
||||
|
||||
Request Body:
|
||||
- description: string (required) - Transaction description
|
||||
- amount: number (required) - Transaction amount (max 2 decimal places)
|
||||
- type: string (required) - 'income' or 'expense'
|
||||
- category_id: integer (optional) - Category ID
|
||||
- transaction_date: string (optional, default: today) - Date in YYYY-MM-DD format
|
||||
- notes: string (optional) - Additional notes
|
||||
|
||||
Returns:
|
||||
- 201: Created transaction
|
||||
- 400: Validation error
|
||||
- 404: Category not found
|
||||
"""
|
||||
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 = transaction_create_schema.load(json_data)
|
||||
|
||||
# Validate category exists if provided
|
||||
if validated_data.get('category_id'):
|
||||
category = Category.query.get(validated_data['category_id'])
|
||||
if not category:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'not_found',
|
||||
'message': f'Category with ID {validated_data["category_id"]} not found'
|
||||
}), 404
|
||||
|
||||
if not category.is_active:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Cannot assign transaction to inactive category'
|
||||
}), 400
|
||||
|
||||
# Create new transaction
|
||||
transaction = Transaction(**validated_data)
|
||||
db.session.add(transaction)
|
||||
db.session.commit()
|
||||
|
||||
# Return created transaction with category name
|
||||
data = transaction_detail_schema.dump(transaction)
|
||||
if transaction.category:
|
||||
data['category_name'] = transaction.category.name
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': data,
|
||||
'message': 'Transaction created successfully'
|
||||
}), 201
|
||||
|
||||
except ValidationError as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Input validation failed',
|
||||
'details': e.messages
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'internal_server_error',
|
||||
'message': 'Failed to create transaction'
|
||||
}), 500
|
||||
|
||||
|
||||
@transactions_bp.route('/<int:transaction_id>', methods=['GET'])
|
||||
def get_transaction(transaction_id):
|
||||
"""
|
||||
Get a specific transaction by ID.
|
||||
|
||||
Path Parameters:
|
||||
- transaction_id: integer - Transaction ID
|
||||
|
||||
Returns:
|
||||
- 200: Transaction details with category information
|
||||
- 404: Transaction not found
|
||||
"""
|
||||
try:
|
||||
transaction = Transaction.query.get(transaction_id)
|
||||
if not transaction:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'not_found',
|
||||
'message': f'Transaction with ID {transaction_id} not found'
|
||||
}), 404
|
||||
|
||||
# Serialize transaction with category name
|
||||
data = transaction_detail_schema.dump(transaction)
|
||||
if transaction.category:
|
||||
data['category_name'] = transaction.category.name
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': data
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'internal_server_error',
|
||||
'message': 'Failed to retrieve transaction'
|
||||
}), 500
|
||||
|
||||
|
||||
@transactions_bp.route('/<int:transaction_id>', methods=['PUT'])
|
||||
def update_transaction(transaction_id):
|
||||
"""
|
||||
Update a specific transaction.
|
||||
|
||||
Path Parameters:
|
||||
- transaction_id: integer - Transaction ID
|
||||
|
||||
Request Body:
|
||||
- description: string (optional) - Transaction description
|
||||
- amount: number (optional) - Transaction amount
|
||||
- type: string (optional) - 'income' or 'expense'
|
||||
- category_id: integer (optional) - Category ID
|
||||
- transaction_date: string (optional) - Date in YYYY-MM-DD format
|
||||
- notes: string (optional) - Additional notes
|
||||
|
||||
Returns:
|
||||
- 200: Updated transaction
|
||||
- 400: Validation error
|
||||
- 404: Transaction or category not found
|
||||
"""
|
||||
try:
|
||||
transaction = Transaction.query.get(transaction_id)
|
||||
if not transaction:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'not_found',
|
||||
'message': f'Transaction with ID {transaction_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 = transaction_update_schema.load(json_data)
|
||||
|
||||
# Validate category exists if being updated
|
||||
if 'category_id' in validated_data and validated_data['category_id']:
|
||||
category = Category.query.get(validated_data['category_id'])
|
||||
if not category:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'not_found',
|
||||
'message': f'Category with ID {validated_data["category_id"]} not found'
|
||||
}), 404
|
||||
|
||||
if not category.is_active:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Cannot assign transaction to inactive category'
|
||||
}), 400
|
||||
|
||||
# Update transaction fields
|
||||
for field, value in validated_data.items():
|
||||
setattr(transaction, field, value)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Return updated transaction with category name
|
||||
data = transaction_detail_schema.dump(transaction)
|
||||
if transaction.category:
|
||||
data['category_name'] = transaction.category.name
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': data,
|
||||
'message': 'Transaction updated successfully'
|
||||
}), 200
|
||||
|
||||
except ValidationError as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'validation_failed',
|
||||
'message': 'Input validation failed',
|
||||
'details': e.messages
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'internal_server_error',
|
||||
'message': 'Failed to update transaction'
|
||||
}), 500
|
||||
|
||||
|
||||
@transactions_bp.route('/<int:transaction_id>', methods=['DELETE'])
|
||||
def delete_transaction(transaction_id):
|
||||
"""
|
||||
Delete a specific transaction.
|
||||
|
||||
Path Parameters:
|
||||
- transaction_id: integer - Transaction ID
|
||||
|
||||
Returns:
|
||||
- 200: Transaction deleted successfully
|
||||
- 404: Transaction not found
|
||||
"""
|
||||
try:
|
||||
transaction = Transaction.query.get(transaction_id)
|
||||
if not transaction:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'not_found',
|
||||
'message': f'Transaction with ID {transaction_id} not found'
|
||||
}), 404
|
||||
|
||||
# Hard delete the transaction
|
||||
db.session.delete(transaction)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Transaction deleted successfully'
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'internal_server_error',
|
||||
'message': 'Failed to delete transaction'
|
||||
}), 500
|
97
app/config.py
Normal file
97
app/config.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
"""
|
||||
Application Configuration
|
||||
|
||||
This module contains configuration classes for different environments.
|
||||
Each configuration class inherits from the base Config class and can override
|
||||
specific settings as needed.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
"""Base configuration class with common settings."""
|
||||
|
||||
# Flask settings
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
|
||||
# Database settings
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
|
||||
'postgresql://localhost/personal_finance_dev'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_ECHO = False
|
||||
|
||||
# API settings
|
||||
JSON_SORT_KEYS = False
|
||||
JSONIFY_PRETTYPRINT_REGULAR = True
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development environment configuration."""
|
||||
|
||||
DEBUG = True
|
||||
SQLALCHEMY_ECHO = True # Log SQL queries in development
|
||||
|
||||
# Override database URL for development
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
|
||||
'postgresql://localhost/personal_finance_dev'
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""Testing environment configuration."""
|
||||
|
||||
TESTING = True
|
||||
WTF_CSRF_ENABLED = False
|
||||
|
||||
# Use in-memory SQLite for testing
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
|
||||
'sqlite:///:memory:'
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production environment configuration."""
|
||||
|
||||
# Ensure SECRET_KEY is set in production
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY')
|
||||
if not SECRET_KEY:
|
||||
raise ValueError("SECRET_KEY environment variable must be set in production")
|
||||
|
||||
# Production database URL is required
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
|
||||
if not SQLALCHEMY_DATABASE_URI:
|
||||
raise ValueError("DATABASE_URL environment variable must be set in production")
|
||||
|
||||
# Production optimizations
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_pre_ping': True,
|
||||
'pool_recycle': 300,
|
||||
}
|
||||
|
||||
|
||||
# Configuration mapping
|
||||
config_map = {
|
||||
'development': DevelopmentConfig,
|
||||
'testing': TestingConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
|
||||
|
||||
def get_config(config_name=None):
|
||||
"""
|
||||
Get configuration class for the specified environment.
|
||||
|
||||
Args:
|
||||
config_name (str): Configuration name ('development', 'testing', 'production')
|
||||
|
||||
Returns:
|
||||
Config: Configuration class instance
|
||||
"""
|
||||
if config_name is None:
|
||||
config_name = 'default'
|
||||
|
||||
return config_map.get(config_name, DevelopmentConfig)
|
14
app/extensions.py
Normal file
14
app/extensions.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
"""
|
||||
Flask Extensions
|
||||
|
||||
This module initializes Flask extensions following the application factory pattern.
|
||||
Extensions are initialized here without an app instance, then bound to the app
|
||||
in the create_app function using init_app().
|
||||
"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
|
||||
# Initialize extensions without app instance
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
12
app/models/__init__.py
Normal file
12
app/models/__init__.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
"""
|
||||
Database Models
|
||||
|
||||
This module contains SQLAlchemy model definitions for the Personal Finance API.
|
||||
Models are implemented in separate files and imported here for easy access.
|
||||
"""
|
||||
|
||||
from .category import Category
|
||||
from .transaction import Transaction
|
||||
|
||||
# Export all models for easy importing
|
||||
__all__ = ['Category', 'Transaction']
|
79
app/models/category.py
Normal file
79
app/models/category.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
Category Model
|
||||
|
||||
SQLAlchemy model for transaction categories.
|
||||
Handles categorization of financial transactions.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Category(db.Model):
|
||||
"""
|
||||
Category model for organizing transactions.
|
||||
|
||||
Attributes:
|
||||
id (int): Primary key
|
||||
name (str): Category name (unique)
|
||||
description (str): Optional description
|
||||
color (str): Hex color code for UI representation
|
||||
is_active (bool): Whether category is active
|
||||
created_at (datetime): Creation timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = 'categories'
|
||||
|
||||
# Primary key
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Category information
|
||||
name = db.Column(db.String(100), nullable=False, unique=True)
|
||||
description = db.Column(db.Text)
|
||||
color = db.Column(db.String(7)) # Hex color code (#FFFFFF)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
# Timestamps
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
transactions = db.relationship('Transaction', backref='category', lazy=True)
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of Category."""
|
||||
return f'<Category {self.name}>'
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert category to dictionary for JSON serialization."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'color': self.color,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
def get_transaction_count(self):
|
||||
"""Get transaction count without loading all transactions."""
|
||||
from app.models.transaction import Transaction
|
||||
return db.session.query(Transaction).filter_by(category_id=self.id).count()
|
||||
|
||||
@classmethod
|
||||
def get_categories_with_counts(cls):
|
||||
"""Get categories with transaction counts efficiently."""
|
||||
from app.models.transaction import Transaction
|
||||
return db.session.query(
|
||||
cls,
|
||||
db.func.count(Transaction.id).label('transaction_count')
|
||||
).outerjoin(Transaction).group_by(cls.id).all()
|
||||
|
||||
@classmethod
|
||||
def get_active_categories(cls):
|
||||
"""Get all active categories."""
|
||||
return cls.query.filter_by(is_active=True).all()
|
||||
|
||||
@classmethod
|
||||
def find_by_name(cls, name):
|
||||
"""Find category by name."""
|
||||
return cls.query.filter_by(name=name).first()
|
112
app/models/transaction.py
Normal file
112
app/models/transaction.py
Normal file
|
@ -0,0 +1,112 @@
|
|||
"""
|
||||
Transaction Model
|
||||
|
||||
SQLAlchemy model for financial transactions.
|
||||
Handles income and expense records with precise decimal arithmetic.
|
||||
"""
|
||||
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Transaction(db.Model):
|
||||
"""
|
||||
Transaction model for financial records.
|
||||
|
||||
Attributes:
|
||||
id (int): Primary key
|
||||
description (str): Transaction description
|
||||
amount (Decimal): Transaction amount (precise decimal)
|
||||
type (str): Transaction type ('income' or 'expense')
|
||||
category_id (int): Foreign key to Category
|
||||
transaction_date (date): Date of transaction
|
||||
notes (str): Optional additional notes
|
||||
created_at (datetime): Creation timestamp
|
||||
"""
|
||||
|
||||
__tablename__ = 'transactions'
|
||||
|
||||
# Primary key
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Transaction information
|
||||
description = db.Column(db.String(255), nullable=False)
|
||||
amount = db.Column(db.Numeric(10, 2), nullable=False) # DECIMAL(10,2) for precise money handling
|
||||
type = db.Column(db.String(10), nullable=False) # 'income' or 'expense'
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# Relationships
|
||||
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
|
||||
|
||||
# Dates
|
||||
transaction_date = db.Column(db.Date, nullable=False, default=date.today)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Database constraints
|
||||
__table_args__ = (
|
||||
db.CheckConstraint('amount > 0', name='check_amount_positive'),
|
||||
db.CheckConstraint("type IN ('income', 'expense')", name='check_type_valid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of Transaction."""
|
||||
return f'<Transaction {self.type}: {self.amount} - {self.description}>'
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert transaction to dictionary for JSON serialization."""
|
||||
return {
|
||||
'id': self.id,
|
||||
'description': self.description,
|
||||
'amount': float(self.amount) if self.amount else 0,
|
||||
'type': self.type,
|
||||
'category_id': self.category_id,
|
||||
'category_name': self.category.name if self.category else None,
|
||||
'transaction_date': self.transaction_date.isoformat() if self.transaction_date else None,
|
||||
'notes': self.notes,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
@property
|
||||
def amount_decimal(self):
|
||||
"""Get amount as Decimal object for precise calculations."""
|
||||
return Decimal(str(self.amount)) if self.amount else Decimal('0')
|
||||
|
||||
@classmethod
|
||||
def get_by_type(cls, transaction_type):
|
||||
"""Get transactions by type (income/expense)."""
|
||||
return cls.query.filter_by(type=transaction_type).all()
|
||||
|
||||
@classmethod
|
||||
def get_by_date_range(cls, start_date, end_date):
|
||||
"""Get transactions within date range."""
|
||||
return cls.query.filter(
|
||||
cls.transaction_date >= start_date,
|
||||
cls.transaction_date <= end_date
|
||||
).all()
|
||||
|
||||
@classmethod
|
||||
def get_by_category(cls, category_id):
|
||||
"""Get transactions by category."""
|
||||
return cls.query.filter_by(category_id=category_id).all()
|
||||
|
||||
@classmethod
|
||||
def calculate_total_by_type(cls, transaction_type):
|
||||
"""Calculate total amount for a transaction type."""
|
||||
result = db.session.query(db.func.sum(cls.amount)).filter_by(type=transaction_type).scalar()
|
||||
return Decimal(str(result)) if result else Decimal('0')
|
||||
|
||||
@classmethod
|
||||
def get_summary_stats(cls):
|
||||
"""Get summary statistics for all transactions."""
|
||||
total_income = cls.calculate_total_by_type('income')
|
||||
total_expenses = cls.calculate_total_by_type('expense')
|
||||
net_balance = total_income - total_expenses
|
||||
transaction_count = cls.query.count()
|
||||
|
||||
return {
|
||||
'total_income': float(total_income),
|
||||
'total_expenses': float(total_expenses),
|
||||
'net_balance': float(net_balance),
|
||||
'transaction_count': transaction_count
|
||||
}
|
36
app/schemas/__init__.py
Normal file
36
app/schemas/__init__.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
"""
|
||||
Marshmallow Schemas
|
||||
|
||||
This module contains Marshmallow schemas for request/response validation and serialization.
|
||||
Schemas are implemented in separate files and imported here for easy access.
|
||||
"""
|
||||
|
||||
from .category import (
|
||||
CategorySchema,
|
||||
CategoryCreateSchema,
|
||||
CategoryUpdateSchema,
|
||||
CategoryListSchema
|
||||
)
|
||||
|
||||
from .transaction import (
|
||||
TransactionSchema,
|
||||
TransactionCreateSchema,
|
||||
TransactionUpdateSchema,
|
||||
TransactionListSchema,
|
||||
TransactionFilterSchema,
|
||||
DecimalField
|
||||
)
|
||||
|
||||
# Export all schemas for easy importing
|
||||
__all__ = [
|
||||
'CategorySchema',
|
||||
'CategoryCreateSchema',
|
||||
'CategoryUpdateSchema',
|
||||
'CategoryListSchema',
|
||||
'TransactionSchema',
|
||||
'TransactionCreateSchema',
|
||||
'TransactionUpdateSchema',
|
||||
'TransactionListSchema',
|
||||
'TransactionFilterSchema',
|
||||
'DecimalField'
|
||||
]
|
103
app/schemas/category.py
Normal file
103
app/schemas/category.py
Normal file
|
@ -0,0 +1,103 @@
|
|||
"""
|
||||
Category Marshmallow Schema
|
||||
|
||||
Validation and serialization schema for Category model.
|
||||
Handles input validation, data transformation, and JSON serialization.
|
||||
"""
|
||||
|
||||
from marshmallow import Schema, fields, validate, validates, ValidationError
|
||||
import re
|
||||
|
||||
|
||||
class CategorySchema(Schema):
|
||||
"""
|
||||
Schema for Category model validation and serialization.
|
||||
|
||||
Provides input validation for:
|
||||
- Name: Required, 1-100 characters, unique
|
||||
- Description: Optional text field
|
||||
- Color: Optional hex color code validation
|
||||
- is_active: Boolean field with default True
|
||||
"""
|
||||
|
||||
# Fields with validation
|
||||
id = fields.Integer(dump_only=True) # Read-only field
|
||||
name = fields.String(
|
||||
required=True,
|
||||
validate=[
|
||||
validate.Length(min=1, max=100, error="Name must be 1-100 characters"),
|
||||
validate.Regexp(
|
||||
r'^[a-zA-Z0-9\s\-_&]+$',
|
||||
error="Name can only contain letters, numbers, spaces, hyphens, underscores, and ampersands"
|
||||
)
|
||||
]
|
||||
)
|
||||
description = fields.String(
|
||||
allow_none=True,
|
||||
validate=validate.Length(max=500, error="Description cannot exceed 500 characters")
|
||||
)
|
||||
color = fields.String(
|
||||
allow_none=True,
|
||||
validate=validate.Length(equal=7, error="Color must be exactly 7 characters (e.g., #FF5733)")
|
||||
)
|
||||
is_active = fields.Boolean(load_default=True)
|
||||
created_at = fields.DateTime(dump_only=True, format='iso')
|
||||
|
||||
@validates('color')
|
||||
def validate_color(self, value, **kwargs):
|
||||
"""Validate hex color format."""
|
||||
if value is not None:
|
||||
# Check if it's a valid hex color code
|
||||
hex_pattern = r'^#[0-9A-Fa-f]{6}$'
|
||||
if not re.match(hex_pattern, value):
|
||||
raise ValidationError("Color must be a valid hex code (e.g., #FF5733)")
|
||||
|
||||
class Meta:
|
||||
"""Schema configuration."""
|
||||
ordered = True # Preserve field order in output
|
||||
|
||||
|
||||
class CategoryCreateSchema(CategorySchema):
|
||||
"""Schema for creating new categories."""
|
||||
|
||||
class Meta:
|
||||
"""Schema configuration for creation."""
|
||||
exclude = ('id', 'created_at')
|
||||
ordered = True
|
||||
|
||||
|
||||
class CategoryUpdateSchema(CategorySchema):
|
||||
"""Schema for updating existing categories."""
|
||||
|
||||
# Make all fields optional for partial updates
|
||||
name = fields.String(
|
||||
required=False,
|
||||
validate=[
|
||||
validate.Length(min=1, max=100, error="Name must be 1-100 characters"),
|
||||
validate.Regexp(
|
||||
r'^[a-zA-Z0-9\s\-_&]+$',
|
||||
error="Name can only contain letters, numbers, spaces, hyphens, underscores, and ampersands"
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Schema configuration for updates."""
|
||||
exclude = ('id', 'created_at')
|
||||
ordered = True
|
||||
|
||||
|
||||
class CategoryListSchema(Schema):
|
||||
"""Schema for category list with optional transaction count."""
|
||||
|
||||
id = fields.Integer()
|
||||
name = fields.String()
|
||||
description = fields.String(allow_none=True)
|
||||
color = fields.String(allow_none=True)
|
||||
is_active = fields.Boolean()
|
||||
created_at = fields.DateTime(format='iso')
|
||||
transaction_count = fields.Integer(allow_none=True)
|
||||
|
||||
class Meta:
|
||||
"""Schema configuration."""
|
||||
ordered = True
|
219
app/schemas/transaction.py
Normal file
219
app/schemas/transaction.py
Normal file
|
@ -0,0 +1,219 @@
|
|||
"""
|
||||
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
|
7
app/utils/__init__.py
Normal file
7
app/utils/__init__.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Utility Functions
|
||||
|
||||
This module contains helper functions and utilities used across the application.
|
||||
"""
|
||||
|
||||
# Utility functions will be added here as needed
|
688
frontend.html
Normal file
688
frontend.html
Normal file
|
@ -0,0 +1,688 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Personal Finance Tracker</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: white;
|
||||
margin-bottom: 2rem;
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
color: #667eea;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.8rem;
|
||||
border-bottom: 3px solid #667eea;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 2rem;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row-3 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e1e5e9;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.category-color {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.amount-income {
|
||||
color: #28a745;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amount-expense {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row, .form-row-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>💰 Personal Finance Tracker</h1>
|
||||
<p class="subtitle">Manage your income, expenses, and financial goals</p>
|
||||
</header>
|
||||
|
||||
<!-- Analytics Summary Section -->
|
||||
<div class="section">
|
||||
<h2>📊 Financial Summary</h2>
|
||||
<div id="analytics-loading" class="loading">Loading financial data...</div>
|
||||
<div id="analytics-content" style="display: none;">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value amount-income" id="total-income">$0.00</div>
|
||||
<div class="stat-label">Total Income</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value amount-expense" id="total-expenses">$0.00</div>
|
||||
<div class="stat-label">Total Expenses</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="net-balance">$0.00</div>
|
||||
<div class="stat-label">Net Balance</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="transaction-count">0</div>
|
||||
<div class="stat-label">Transactions</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="loadAnalytics()">🔄 Refresh Analytics</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories Section -->
|
||||
<div class="section">
|
||||
<h2>🏷️ Categories</h2>
|
||||
|
||||
<!-- Add Category Form -->
|
||||
<div style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin-bottom: 2rem;">
|
||||
<h3 style="margin-bottom: 1rem; color: #667eea;">Add New Category</h3>
|
||||
<form id="category-form">
|
||||
<div class="form-row-3">
|
||||
<div class="form-group">
|
||||
<label for="category-name">Category Name</label>
|
||||
<input type="text" id="category-name" required placeholder="e.g., Food, Transportation">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category-description">Description</label>
|
||||
<input type="text" id="category-description" placeholder="Optional description">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category-color">Color</label>
|
||||
<input type="color" id="category-color" value="#667eea">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">➕ Add Category</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Categories List -->
|
||||
<div id="categories-loading" class="loading">Loading categories...</div>
|
||||
<div id="categories-content" style="display: none;">
|
||||
<table id="categories-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Color</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="categories-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions Section -->
|
||||
<div class="section">
|
||||
<h2>💳 Transactions</h2>
|
||||
|
||||
<!-- Add Transaction Form -->
|
||||
<div style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin-bottom: 2rem;">
|
||||
<h3 style="margin-bottom: 1rem; color: #667eea;">Add New Transaction</h3>
|
||||
<form id="transaction-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="transaction-description">Description</label>
|
||||
<input type="text" id="transaction-description" required placeholder="e.g., Grocery shopping">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="transaction-amount">Amount</label>
|
||||
<input type="number" id="transaction-amount" step="0.01" min="0.01" required placeholder="0.00">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row-3">
|
||||
<div class="form-group">
|
||||
<label for="transaction-type">Type</label>
|
||||
<select id="transaction-type" required>
|
||||
<option value="">Select type</option>
|
||||
<option value="income">💰 Income</option>
|
||||
<option value="expense">💸 Expense</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="transaction-category">Category</label>
|
||||
<select id="transaction-category">
|
||||
<option value="">No category</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="transaction-date">Date</label>
|
||||
<input type="date" id="transaction-date" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="transaction-notes">Notes (Optional)</label>
|
||||
<textarea id="transaction-notes" rows="2" placeholder="Additional notes..."></textarea>
|
||||
</div>
|
||||
<button type="submit">➕ Add Transaction</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Transactions List -->
|
||||
<div id="transactions-loading" class="loading">Loading transactions...</div>
|
||||
<div id="transactions-content" style="display: none;">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<button onclick="loadTransactions()">🔄 Refresh Transactions</button>
|
||||
</div>
|
||||
<table id="transactions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Category</th>
|
||||
<th>Amount</th>
|
||||
<th>Type</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="transactions-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = 'http://127.0.0.1:5000/api/v1';
|
||||
|
||||
// Initialize the application
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set today's date as default
|
||||
document.getElementById('transaction-date').value = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Load initial data
|
||||
loadCategories();
|
||||
loadTransactions();
|
||||
loadAnalytics();
|
||||
|
||||
// Set up form handlers
|
||||
setupFormHandlers();
|
||||
});
|
||||
|
||||
function setupFormHandlers() {
|
||||
// Category form handler
|
||||
document.getElementById('category-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
await addCategory();
|
||||
});
|
||||
|
||||
// Transaction form handler
|
||||
document.getElementById('transaction-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
await addTransaction();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/categories/`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
displayCategories(data.data);
|
||||
updateCategoryDropdown(data.data);
|
||||
} else {
|
||||
showError('Failed to load categories');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Error loading categories: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function displayCategories(categories) {
|
||||
const tbody = document.getElementById('categories-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
categories.forEach(category => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td><span class="category-color" style="background-color: ${category.color || '#667eea'}"></span></td>
|
||||
<td>${category.name}</td>
|
||||
<td>${category.description || '-'}</td>
|
||||
<td>${new Date(category.created_at).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<button class="btn-danger" onclick="deleteCategory(${category.id})" style="padding: 0.3rem 0.8rem; font-size: 0.8rem;">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
document.getElementById('categories-loading').style.display = 'none';
|
||||
document.getElementById('categories-content').style.display = 'block';
|
||||
}
|
||||
|
||||
function updateCategoryDropdown(categories) {
|
||||
const select = document.getElementById('transaction-category');
|
||||
select.innerHTML = '<option value="">No category</option>';
|
||||
|
||||
categories.forEach(category => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category.id;
|
||||
option.textContent = category.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function addCategory() {
|
||||
const name = document.getElementById('category-name').value;
|
||||
const description = document.getElementById('category-description').value;
|
||||
const color = document.getElementById('category-color').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/categories/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
description: description || null,
|
||||
color: color
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccess('Category added successfully!');
|
||||
document.getElementById('category-form').reset();
|
||||
document.getElementById('category-color').value = '#667eea';
|
||||
loadCategories();
|
||||
} else {
|
||||
showError(data.message || 'Failed to add category');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Error adding category: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCategory(id) {
|
||||
if (!confirm('Are you sure you want to delete this category?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/categories/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccess('Category deleted successfully!');
|
||||
loadCategories();
|
||||
} else {
|
||||
showError(data.message || 'Failed to delete category');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Error deleting category: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/transactions/`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
displayTransactions(data.data);
|
||||
} else {
|
||||
showError('Failed to load transactions');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Error loading transactions: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function displayTransactions(transactions) {
|
||||
const tbody = document.getElementById('transactions-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
transactions.forEach(transaction => {
|
||||
const row = document.createElement('tr');
|
||||
const amountClass = transaction.type === 'income' ? 'amount-income' : 'amount-expense';
|
||||
const typeIcon = transaction.type === 'income' ? '💰' : '💸';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${new Date(transaction.transaction_date).toLocaleDateString()}</td>
|
||||
<td>${transaction.description}</td>
|
||||
<td>${transaction.category_name || '-'}</td>
|
||||
<td class="${amountClass}">$${parseFloat(transaction.amount).toFixed(2)}</td>
|
||||
<td>${typeIcon} ${transaction.type}</td>
|
||||
<td>
|
||||
<button class="btn-danger" onclick="deleteTransaction(${transaction.id})" style="padding: 0.3rem 0.8rem; font-size: 0.8rem;">Delete</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
document.getElementById('transactions-loading').style.display = 'none';
|
||||
document.getElementById('transactions-content').style.display = 'block';
|
||||
}
|
||||
|
||||
async function addTransaction() {
|
||||
const description = document.getElementById('transaction-description').value;
|
||||
const amount = parseFloat(document.getElementById('transaction-amount').value);
|
||||
const type = document.getElementById('transaction-type').value;
|
||||
const categoryId = document.getElementById('transaction-category').value;
|
||||
const date = document.getElementById('transaction-date').value;
|
||||
const notes = document.getElementById('transaction-notes').value;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
description: description,
|
||||
amount: amount,
|
||||
type: type,
|
||||
transaction_date: date,
|
||||
notes: notes || null
|
||||
};
|
||||
|
||||
if (categoryId) {
|
||||
payload.category_id = parseInt(categoryId);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/transactions/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccess('Transaction added successfully!');
|
||||
document.getElementById('transaction-form').reset();
|
||||
document.getElementById('transaction-date').value = new Date().toISOString().split('T')[0];
|
||||
loadTransactions();
|
||||
loadAnalytics();
|
||||
} else {
|
||||
showError(data.message || 'Failed to add transaction');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Error adding transaction: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTransaction(id) {
|
||||
if (!confirm('Are you sure you want to delete this transaction?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/transactions/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccess('Transaction deleted successfully!');
|
||||
loadTransactions();
|
||||
loadAnalytics();
|
||||
} else {
|
||||
showError(data.message || 'Failed to delete transaction');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Error deleting transaction: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnalytics() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/analytics/summary`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
displayAnalytics(data.data);
|
||||
} else {
|
||||
showError('Failed to load analytics');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Error loading analytics: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function displayAnalytics(analytics) {
|
||||
document.getElementById('total-income').textContent = `$${parseFloat(analytics.total_income).toFixed(2)}`;
|
||||
document.getElementById('total-expenses').textContent = `$${parseFloat(analytics.total_expenses).toFixed(2)}`;
|
||||
document.getElementById('transaction-count').textContent = analytics.transaction_count;
|
||||
|
||||
const netBalance = parseFloat(analytics.net_balance);
|
||||
const balanceElement = document.getElementById('net-balance');
|
||||
balanceElement.textContent = `$${Math.abs(netBalance).toFixed(2)}`;
|
||||
|
||||
if (netBalance >= 0) {
|
||||
balanceElement.className = 'stat-value amount-income';
|
||||
} else {
|
||||
balanceElement.className = 'stat-value amount-expense';
|
||||
balanceElement.textContent = `-$${Math.abs(netBalance).toFixed(2)}`;
|
||||
}
|
||||
|
||||
document.getElementById('analytics-loading').style.display = 'none';
|
||||
document.getElementById('analytics-content').style.display = 'block';
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error';
|
||||
errorDiv.textContent = message;
|
||||
document.body.insertBefore(errorDiv, document.body.firstChild);
|
||||
|
||||
setTimeout(() => {
|
||||
errorDiv.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'success';
|
||||
successDiv.textContent = message;
|
||||
document.body.insertBefore(successDiv, document.body.firstChild);
|
||||
|
||||
setTimeout(() => {
|
||||
successDiv.remove();
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
669
frontend1.html
Normal file
669
frontend1.html
Normal file
|
@ -0,0 +1,669 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Budget Tracker System</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #a8c8ec 0%, #7fb3d3 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 3.5rem;
|
||||
color: #8B7D6B;
|
||||
font-weight: 600;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
font-size: 1.4rem;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
width: 100%;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c82333;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(220, 53, 69, 0.3);
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(135, 206, 250, 0.3);
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(135, 206, 250, 0.5);
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
font-size: 0.9rem;
|
||||
color: #2c5aa0;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #1a4480;
|
||||
}
|
||||
|
||||
.history-section h3 {
|
||||
font-size: 1.4rem;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expense-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.expense-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.expense-table td {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.expense-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Budget Tracker System</h1>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="layout">
|
||||
<!-- Left Panel -->
|
||||
<div class="left-panel">
|
||||
<!-- Add Budget Section -->
|
||||
<div class="card">
|
||||
<h3>Add Budget</h3>
|
||||
<form id="budget-form">
|
||||
<div class="form-group">
|
||||
<label for="budget-amount">Budget:</label>
|
||||
<input type="number" id="budget-amount" step="0.01" min="0.01" required placeholder="Enter budget amount">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Add Budget</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Add Expense Section -->
|
||||
<div class="card">
|
||||
<h3>Add Expense</h3>
|
||||
<form id="expense-form">
|
||||
<div class="form-group">
|
||||
<label for="expense-title">Expense Title:</label>
|
||||
<input type="text" id="expense-title" required placeholder="Enter expense title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="expense-amount">Amount:</label>
|
||||
<input type="number" id="expense-amount" step="0.01" min="0.01" required placeholder="Enter amount">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="expense-category">Category:</label>
|
||||
<select id="expense-category">
|
||||
<option value="">No category</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Add Expense</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<button class="btn-danger" onclick="resetAll()">Reset All</button>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel -->
|
||||
<div class="right-panel">
|
||||
<!-- Budget Statistics -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Budget:</div>
|
||||
<div class="value" id="total-budget">0.00</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Expenses:</div>
|
||||
<div class="value" id="total-expenses">0.00</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Budget Left:</div>
|
||||
<div class="value" id="budget-left">0.00</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expense History -->
|
||||
<div class="history-section">
|
||||
<h3>Expense History:</h3>
|
||||
<div id="expenses-loading" class="loading" style="display: none;">Loading expenses...</div>
|
||||
<div id="expenses-content">
|
||||
<table class="expense-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Expense Name</th>
|
||||
<th>Amount</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="expenses-tbody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="empty-expenses" class="empty-state" style="display: none;">
|
||||
No expenses recorded yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = 'http://127.0.0.1:5000/api/v1';
|
||||
let totalBudget = 0;
|
||||
let expenses = [];
|
||||
|
||||
// Initialize the application
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadCategories();
|
||||
loadExpenses();
|
||||
loadBudgetFromStorage();
|
||||
|
||||
// Set up form handlers
|
||||
setupFormHandlers();
|
||||
});
|
||||
|
||||
function setupFormHandlers() {
|
||||
// Budget form handler
|
||||
document.getElementById('budget-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
addBudget();
|
||||
});
|
||||
|
||||
// Expense form handler
|
||||
document.getElementById('expense-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
addExpense();
|
||||
});
|
||||
}
|
||||
|
||||
function addBudget() {
|
||||
const amount = parseFloat(document.getElementById('budget-amount').value);
|
||||
|
||||
if (amount > 0) {
|
||||
totalBudget += amount;
|
||||
localStorage.setItem('totalBudget', totalBudget.toString());
|
||||
updateBudgetDisplay();
|
||||
document.getElementById('budget-form').reset();
|
||||
showSuccess('Budget added successfully!');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/categories/`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
updateCategoryDropdown(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Categories not available, using default options');
|
||||
updateCategoryDropdown([]);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCategoryDropdown(categories) {
|
||||
const select = document.getElementById('expense-category');
|
||||
select.innerHTML = '<option value="">No category</option>';
|
||||
|
||||
// Add default categories if API is not available
|
||||
if (categories.length === 0) {
|
||||
const defaultCategories = [
|
||||
{ id: 'grocery', name: 'Grocery' },
|
||||
{ id: 'electricity', name: 'Electricity' },
|
||||
{ id: 'loan', name: 'Loan' },
|
||||
{ id: 'shopping', name: 'Shopping' },
|
||||
{ id: 'transport', name: 'Transportation' },
|
||||
{ id: 'food', name: 'Food & Dining' }
|
||||
];
|
||||
|
||||
defaultCategories.forEach(category => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category.id;
|
||||
option.textContent = category.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} else {
|
||||
categories.forEach(category => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category.id;
|
||||
option.textContent = category.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function addExpense() {
|
||||
const title = document.getElementById('expense-title').value;
|
||||
const amount = parseFloat(document.getElementById('expense-amount').value);
|
||||
const categoryId = document.getElementById('expense-category').value;
|
||||
|
||||
const expense = {
|
||||
id: Date.now(),
|
||||
title: title,
|
||||
amount: amount,
|
||||
category: categoryId || 'uncategorized',
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
|
||||
// Try to save to API first
|
||||
try {
|
||||
const payload = {
|
||||
description: title,
|
||||
amount: amount,
|
||||
type: 'expense',
|
||||
transaction_date: expense.date
|
||||
};
|
||||
|
||||
if (categoryId && !isNaN(categoryId)) {
|
||||
payload.category_id = parseInt(categoryId);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/transactions/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Successfully saved to API
|
||||
showSuccess('Expense added successfully!');
|
||||
} else {
|
||||
// API failed, save locally
|
||||
expenses.push(expense);
|
||||
localStorage.setItem('expenses', JSON.stringify(expenses));
|
||||
showSuccess('Expense added (saved locally)!');
|
||||
}
|
||||
} catch (error) {
|
||||
// API not available, save locally
|
||||
expenses.push(expense);
|
||||
localStorage.setItem('expenses', JSON.stringify(expenses));
|
||||
showSuccess('Expense added (saved locally)!');
|
||||
}
|
||||
|
||||
// Always update local display
|
||||
updateExpensesDisplay();
|
||||
updateBudgetDisplay();
|
||||
document.getElementById('expense-form').reset();
|
||||
}
|
||||
|
||||
async function loadExpenses() {
|
||||
// Try to load from API first
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/transactions/?type=expense`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data.length > 0) {
|
||||
// Convert API data to local format
|
||||
expenses = data.data.map(transaction => ({
|
||||
id: transaction.id,
|
||||
title: transaction.description,
|
||||
amount: parseFloat(transaction.amount),
|
||||
category: transaction.category_name || 'uncategorized',
|
||||
date: transaction.transaction_date,
|
||||
apiId: transaction.id
|
||||
}));
|
||||
updateExpensesDisplay();
|
||||
updateBudgetDisplay();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('API not available, loading from local storage');
|
||||
}
|
||||
|
||||
// Fallback to local storage
|
||||
const savedExpenses = localStorage.getItem('expenses');
|
||||
if (savedExpenses) {
|
||||
expenses = JSON.parse(savedExpenses);
|
||||
updateExpensesDisplay();
|
||||
updateBudgetDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function loadBudgetFromStorage() {
|
||||
const savedBudget = localStorage.getItem('totalBudget');
|
||||
if (savedBudget) {
|
||||
totalBudget = parseFloat(savedBudget);
|
||||
updateBudgetDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function updateExpensesDisplay() {
|
||||
const tbody = document.getElementById('expenses-tbody');
|
||||
const emptyState = document.getElementById('empty-expenses');
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (expenses.length === 0) {
|
||||
emptyState.style.display = 'block';
|
||||
document.querySelector('.expense-table').style.display = 'none';
|
||||
} else {
|
||||
emptyState.style.display = 'none';
|
||||
document.querySelector('.expense-table').style.display = 'table';
|
||||
|
||||
expenses.forEach(expense => {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${expense.title}</td>
|
||||
<td>$${expense.amount.toFixed(2)}</td>
|
||||
<td>
|
||||
<button class="btn-remove" onclick="removeExpense(${expense.id})">Remove</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateBudgetDisplay() {
|
||||
const totalExpenses = expenses.reduce((sum, expense) => sum + expense.amount, 0);
|
||||
const budgetLeft = totalBudget - totalExpenses;
|
||||
|
||||
document.getElementById('total-budget').textContent = totalBudget.toFixed(2);
|
||||
document.getElementById('total-expenses').textContent = totalExpenses.toFixed(2);
|
||||
document.getElementById('budget-left').textContent = budgetLeft.toFixed(2);
|
||||
|
||||
// Change color based on budget status
|
||||
const budgetLeftElement = document.getElementById('budget-left');
|
||||
if (budgetLeft < 0) {
|
||||
budgetLeftElement.style.color = '#dc3545';
|
||||
} else if (budgetLeft < totalBudget * 0.2) {
|
||||
budgetLeftElement.style.color = '#ffc107';
|
||||
} else {
|
||||
budgetLeftElement.style.color = '#28a745';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeExpense(expenseId) {
|
||||
if (!confirm('Are you sure you want to remove this expense?')) return;
|
||||
|
||||
const expense = expenses.find(e => e.id === expenseId);
|
||||
if (!expense) return;
|
||||
|
||||
// Try to delete from API if it has an API ID
|
||||
if (expense.apiId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/transactions/${expense.apiId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showSuccess('Expense removed successfully!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not delete from API, removing locally');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from local array
|
||||
expenses = expenses.filter(e => e.id !== expenseId);
|
||||
localStorage.setItem('expenses', JSON.stringify(expenses));
|
||||
|
||||
updateExpensesDisplay();
|
||||
updateBudgetDisplay();
|
||||
showSuccess('Expense removed!');
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
if (!confirm('Are you sure you want to reset all data? This cannot be undone.')) return;
|
||||
|
||||
totalBudget = 0;
|
||||
expenses = [];
|
||||
|
||||
localStorage.removeItem('totalBudget');
|
||||
localStorage.removeItem('expenses');
|
||||
|
||||
updateBudgetDisplay();
|
||||
updateExpensesDisplay();
|
||||
|
||||
showSuccess('All data has been reset!');
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'error';
|
||||
errorDiv.textContent = message;
|
||||
document.body.insertBefore(errorDiv, document.body.firstChild);
|
||||
|
||||
setTimeout(() => {
|
||||
errorDiv.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'success';
|
||||
successDiv.textContent = message;
|
||||
document.body.insertBefore(successDiv, document.body.firstChild);
|
||||
|
||||
setTimeout(() => {
|
||||
successDiv.remove();
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
BIN
instance/personal_finance_dev.db
Normal file
BIN
instance/personal_finance_dev.db
Normal file
Binary file not shown.
6
main.py
Normal file
6
main.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
def main():
|
||||
print("Hello from personal-finance-api!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
1
migrations/README
Normal file
1
migrations/README
Normal file
|
@ -0,0 +1 @@
|
|||
Single-database configuration for Flask.
|
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
|
@ -0,0 +1,50 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
|
@ -0,0 +1,113 @@
|
|||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
|
@ -0,0 +1,52 @@
|
|||
"""Initial migration with Category and Transaction models
|
||||
|
||||
Revision ID: d0397a2e3a6e
|
||||
Revises:
|
||||
Create Date: 2025-09-13 16:05:17.087871
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd0397a2e3a6e'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('categories',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('transactions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('type', sa.String(length=10), nullable=False),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('transaction_date', sa.Date(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.CheckConstraint("type IN ('income', 'expense')", name='check_type_valid'),
|
||||
sa.CheckConstraint('amount > 0', name='check_amount_positive'),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('transactions')
|
||||
op.drop_table('categories')
|
||||
# ### end Alembic commands ###
|
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
|
@ -0,0 +1,17 @@
|
|||
[project]
|
||||
name = "personal-finance-api"
|
||||
version = "0.1.0"
|
||||
description = "Personal Finance Tracking API Backend using Flask and PostgreSQL"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"flask>=3.0.0",
|
||||
"flask-sqlalchemy>=3.1.0",
|
||||
"flask-migrate>=4.0.0",
|
||||
"marshmallow>=3.20.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"psycopg2-binary>=2.9.0",
|
||||
"pytest>=7.4.0",
|
||||
"pytest-flask>=1.3.0",
|
||||
"flask-cors>=6.0.1",
|
||||
]
|
28
run.py
Normal file
28
run.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
"""
|
||||
Flask Application Entry Point
|
||||
|
||||
This script creates and runs the Flask application using the application factory pattern.
|
||||
Use this for development and testing purposes.
|
||||
"""
|
||||
|
||||
import os
|
||||
from app import create_app
|
||||
|
||||
# Create application instance
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Development server configuration
|
||||
debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() == 'true'
|
||||
port = int(os.getenv('FLASK_PORT', 5000))
|
||||
host = os.getenv('FLASK_HOST', '127.0.0.1')
|
||||
|
||||
print(f"🚀 Starting Personal Finance API on {host}:{port}")
|
||||
print(f"📊 Debug mode: {debug_mode}")
|
||||
print(f"🔧 Environment: {os.getenv('FLASK_ENV', 'development')}")
|
||||
|
||||
app.run(
|
||||
host=host,
|
||||
port=port,
|
||||
debug=debug_mode
|
||||
)
|
402
uv.lock
Normal file
402
uv.lock
Normal file
|
@ -0,0 +1,402 @@
|
|||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.16.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload_time = "2025-08-27T18:02:05.668Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload_time = "2025-08-27T18:02:07.37Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload_time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload_time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload_time = "2025-05-20T23:19:49.832Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload_time = "2025-05-20T23:19:47.796Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload_time = "2025-08-19T21:03:21.205Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload_time = "2025-08-19T21:03:19.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-cors"
|
||||
version = "6.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/37/bcfa6c7d5eec777c4c7cf45ce6b27631cebe5230caf88d85eadd63edd37a/flask_cors-6.0.1.tar.gz", hash = "sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db", size = 13463, upload_time = "2025-06-11T01:32:08.518Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f8/01bf35a3afd734345528f98d0353f2a978a476528ad4d7e78b70c4d149dd/flask_cors-6.0.1-py3-none-any.whl", hash = "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", size = 13244, upload_time = "2025-06-11T01:32:07.352Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-migrate"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965, upload_time = "2025-01-10T18:51:11.848Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237, upload_time = "2025-01-10T18:51:09.527Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-sqlalchemy"
|
||||
version = "3.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload_time = "2023-09-11T21:42:36.147Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload_time = "2023-09-11T21:42:34.514Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.2.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload_time = "2025-08-07T13:24:33.51Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload_time = "2025-08-07T13:15:45.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload_time = "2025-08-07T13:42:56.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload_time = "2025-08-07T13:45:27.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload_time = "2025-08-07T13:53:15.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload_time = "2025-08-07T13:18:30.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload_time = "2025-08-07T13:18:28.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload_time = "2025-08-07T13:42:39.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload_time = "2025-08-07T13:18:22.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload_time = "2025-08-07T13:38:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload_time = "2025-08-07T13:15:50.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload_time = "2025-08-07T13:42:57.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload_time = "2025-08-07T13:45:29.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload_time = "2025-08-07T13:53:16.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload_time = "2025-08-07T13:18:32.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload_time = "2025-08-07T13:18:31.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload_time = "2025-08-07T13:42:41.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload_time = "2025-08-07T13:18:24.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload_time = "2025-08-07T13:24:38.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload_time = "2025-08-07T13:16:08.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload_time = "2025-08-07T13:42:59.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload_time = "2025-08-07T13:45:30.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload_time = "2025-08-07T13:53:17.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload_time = "2025-08-07T13:18:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload_time = "2025-08-07T13:18:33.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload_time = "2025-08-07T13:32:27.59Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload_time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload_time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload_time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload_time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload_time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload_time = "2025-04-10T12:44:31.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload_time = "2025-04-10T12:50:53.297Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload_time = "2024-10-18T15:21:54.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload_time = "2024-10-18T15:21:13.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload_time = "2024-10-18T15:21:14.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload_time = "2024-10-18T15:21:15.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload_time = "2024-10-18T15:21:17.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload_time = "2024-10-18T15:21:18.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload_time = "2024-10-18T15:21:18.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload_time = "2024-10-18T15:21:19.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload_time = "2024-10-18T15:21:20.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload_time = "2024-10-18T15:21:22.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload_time = "2024-10-18T15:21:23.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload_time = "2024-10-18T15:21:24.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload_time = "2024-10-18T15:21:25.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload_time = "2024-10-18T15:21:26.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload_time = "2024-10-18T15:21:27.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload_time = "2024-10-18T15:21:27.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload_time = "2024-10-18T15:21:28.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload_time = "2024-10-18T15:21:29.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload_time = "2024-10-18T15:21:30.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload_time = "2024-10-18T15:21:31.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload_time = "2024-10-18T15:21:32.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload_time = "2024-10-18T15:21:33.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload_time = "2024-10-18T15:21:34.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload_time = "2024-10-18T15:21:35.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload_time = "2024-10-18T15:21:36.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload_time = "2024-10-18T15:21:37.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload_time = "2024-10-18T15:21:37.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload_time = "2024-10-18T15:21:39.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload_time = "2024-10-18T15:21:40.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload_time = "2024-10-18T15:21:41.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload_time = "2024-10-18T15:21:42.784Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "marshmallow"
|
||||
version = "4.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/ff/8f092fe402ef12aa71b7f4ceba0c557ce4d5876a9cf421e01a67b7210560/marshmallow-4.0.1.tar.gz", hash = "sha256:e1d860bd262737cb2d34e1541b84cb52c32c72c9474e3fe6f30f137ef8b0d97f", size = 220453, upload_time = "2025-08-28T15:01:37.044Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/18/297efc62b3539b9cd379fc49be3740a02e4c8a43e486f50322cfe0b9568a/marshmallow-4.0.1-py3-none-any.whl", hash = "sha256:72f14ef346f81269dbddee891bac547dda1501e9e08b6a809756ea3dbb7936a1", size = 48414, upload_time = "2025-08-28T15:01:35.221Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "personal-finance-api"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "flask-cors" },
|
||||
{ name = "flask-migrate" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "marshmallow" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-flask" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "flask", specifier = ">=3.0.0" },
|
||||
{ name = "flask-cors", specifier = ">=6.0.1" },
|
||||
{ name = "flask-migrate", specifier = ">=4.0.0" },
|
||||
{ name = "flask-sqlalchemy", specifier = ">=3.1.0" },
|
||||
{ name = "marshmallow", specifier = ">=3.20.0" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.0" },
|
||||
{ name = "pytest", specifier = ">=7.4.0" },
|
||||
{ name = "pytest-flask", specifier = ">=1.3.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload_time = "2024-10-16T11:24:58.126Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload_time = "2024-10-16T11:20:35.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload_time = "2024-10-16T11:20:38.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload_time = "2024-10-16T11:20:42.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload_time = "2024-10-16T11:20:46.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload_time = "2024-10-16T11:20:50.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload_time = "2024-10-16T11:20:56.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload_time = "2024-10-16T11:21:02.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload_time = "2024-10-16T11:21:09.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload_time = "2024-10-16T11:21:16.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload_time = "2024-10-16T11:21:25.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload_time = "2024-10-16T11:21:29.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload_time = "2024-10-16T11:21:34.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload_time = "2024-10-16T11:21:42.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload_time = "2024-10-16T11:21:51.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload_time = "2024-10-16T11:21:57.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload_time = "2024-10-16T11:22:02.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload_time = "2024-10-16T11:22:06.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload_time = "2024-10-16T11:22:11.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload_time = "2024-10-16T11:22:16.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload_time = "2024-10-16T11:22:21.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload_time = "2024-10-16T11:22:25.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload_time = "2024-10-16T11:22:30.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload_time = "2025-01-04T20:09:19.234Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload_time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload_time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload_time = "2025-09-04T14:34:22.711Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload_time = "2025-09-04T14:34:20.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-flask"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "pytest" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/23/32b36d2f769805c0f3069ca8d9eeee77b27fcf86d41d40c6061ddce51c7d/pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e", size = 35816, upload_time = "2023-10-23T14:53:20.696Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/03/7a917fda3d0e96b4e80ab1f83a6628ec4ee4a882523b49417d3891bacc9e/pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253", size = 13105, upload_time = "2023-10-23T14:53:18.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload_time = "2025-06-24T04:21:07.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload_time = "2025-06-24T04:21:06.073Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.43"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload_time = "2025-08-11T14:24:58.438Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891, upload_time = "2025-08-11T15:51:13.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061, upload_time = "2025-08-11T15:51:14.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384, upload_time = "2025-08-11T15:52:35.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648, upload_time = "2025-08-11T15:56:34.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030, upload_time = "2025-08-11T15:52:36.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469, upload_time = "2025-08-11T15:56:35.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906, upload_time = "2025-08-11T15:55:00.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260, upload_time = "2025-08-11T15:55:02.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload_time = "2025-08-11T15:51:15.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload_time = "2025-08-11T15:51:17.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload_time = "2025-08-11T15:52:38.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload_time = "2025-08-11T15:56:37.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload_time = "2025-08-11T15:52:39.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload_time = "2025-08-11T15:56:39.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload_time = "2025-08-11T15:55:05.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload_time = "2025-08-11T15:55:07.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload_time = "2025-08-11T15:39:53.024Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload_time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload_time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload_time = "2024-11-08T15:52:18.093Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload_time = "2024-11-08T15:52:16.132Z" },
|
||||
]
|
Loading…
Reference in a new issue