Initial commit

This commit is contained in:
Pratik Narola 2025-09-13 17:40:23 +05:30
commit 542a633603
30 changed files with 3776 additions and 0 deletions

17
.env.example Normal file
View 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
View 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
View file

@ -0,0 +1 @@
3.12

0
README.md Normal file
View file

108
app/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

Binary file not shown.

6
main.py Normal file
View file

@ -0,0 +1,6 @@
def main():
print("Hello from personal-finance-api!")
if __name__ == "__main__":
main()

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View 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
View 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
View 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"}

View file

@ -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
View 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
View 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
View 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" },
]