Python项目实战
1. 项目概述
在前几天的学习中,我们已经掌握了Python的基础知识、Web开发框架、数据分析与可视化以及测试与调试技术。今天,我们将把这些知识整合起来,构建一个完整的实际项目。
项目开发流程图
项目选择:个人财务管理系统
我们将构建一个个人财务管理系统,具有以下功能:
- 用户认证:注册、登录和注销
- 交易管理:添加、编辑、删除和查看交易记录
- 分类管理:创建和管理交易分类
- 数据可视化:展示收入和支出的图表
- 报表生成:生成财务报表
- 预算设置:设置和跟踪预算
这个项目将使用以下技术:
- 后端:Flask框架
- 数据库:SQLite(简单部署)或PostgreSQL(生产环境)
- ORM:SQLAlchemy
- 前端:HTML、CSS、JavaScript和Bootstrap
- 数据可视化:Chart.js
- 测试:pytest
2. 项目规划
需求分析
在开始编码之前,我们需要明确项目的需求:
功能需求
-
用户管理
- 用户注册(用户名、电子邮件、密码)
- 用户登录和注销
- 用户个人资料管理
-
交易管理
- 添加交易(日期、金额、类别、描述)
- 编辑和删除交易
- 查看交易列表(支持筛选和排序)
-
分类管理
- 创建交易分类(名称、类型:收入/支出)
- 编辑和删除分类
- 查看分类列表
-
数据可视化
- 按类别的收入和支出饼图
- 按时间的收入和支出折线图
- 预算与实际支出的对比图
-
报表
- 月度和年度财务报表
- 导出报表(CSV、PDF)
-
预算
- 设置月度预算(总体和分类)
- 跟踪预算使用情况
非功能需求
- 安全性:密码加密、CSRF保护、输入验证
- 性能:页面加载时间小于2秒
- 可用性:响应式设计,适配移动设备
- 可靠性:数据备份和恢复机制
系统架构
我们将采用经典的MVC(模型-视图-控制器)架构:
- 模型(Model):数据库模型和业务逻辑
- 视图(View):HTML模板和前端代码
- 控制器(Controller):Flask路由和视图函数
数据库设计
我们需要以下数据表:
-
用户(users)
- id: 主键
- username: 用户名
- email: 电子邮件
- password_hash: 密码哈希
- created_at: 创建时间
-
分类(categories)
- id: 主键
- name: 分类名称
- type: 类型(收入/支出)
- user_id: 外键,关联用户
-
交易(transactions)
- id: 主键
- amount: 金额
- date: 日期
- description: 描述
- category_id: 外键,关联分类
- user_id: 外键,关联用户
- created_at: 创建时间
-
预算(budgets)
- id: 主键
- amount: 金额
- month: 月份
- year: 年份
- category_id: 外键,关联分类(可选)
- user_id: 外键,关联用户
项目结构
csharp
finance_app/
├── app/
│ ├── __init__.py # 应用初始化
│ ├── models.py # 数据库模型
│ ├── forms.py # 表单定义
│ ├── routes/ # 路由和视图函数
│ │ ├── __init__.py
│ │ ├── auth.py # 认证相关路由
│ │ ├── main.py # 主页和通用路由
│ │ ├── transactions.py # 交易相关路由
│ │ ├── categories.py # 分类相关路由
│ │ ├── reports.py # 报表相关路由
│ │ └── budgets.py # 预算相关路由
│ ├── static/ # 静态文件
│ │ ├── css/
│ │ ├── js/
│ │ └── img/
│ └── templates/ # HTML模板
│ ├── base.html
│ ├── index.html
│ ├── auth/
│ ├── transactions/
│ ├── categories/
│ ├── reports/
│ └── budgets/
├── migrations/ # 数据库迁移文件
├── tests/ # 测试文件
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_routes.py
│ └── test_forms.py
├── config.py # 配置文件
├── run.py # 应用入口
├── requirements.txt # 依赖列表
└── README.md # 项目说明
3. 项目实现
现在,让我们开始实现这个项目。我们将按照以下步骤进行:
- 设置项目环境
- 实现数据库模型
- 创建用户认证功能
- 实现交易和分类管理
- 添加数据可视化和报表功能
- 实现预算管理
- 编写测试
- 部署应用
3.1 设置项目环境
首先,我们需要创建项目目录并安装必要的依赖:
bash
# 创建项目目录
mkdir finance_app
cd finance_app
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows
venv\Scripts\activate
# macOS/Linux
source venv/bin/activate
# 安装依赖
pip install flask flask-sqlalchemy flask-migrate flask-login flask-wtf email-validator flask-bcrypt
pip install pytest pytest-flask
创建基本的项目结构:
bash
mkdir -p app/static/{css,js,img}
mkdir -p app/templates/{auth,transactions,categories,reports,budgets}
mkdir -p app/routes
mkdir tests
3.2 配置文件
创建配置文件config.py
:
python
import os
from datetime import timedelta
class Config:
# 基本配置
SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard-to-guess-string'
# 数据库配置
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(os.path.abspath(os.path.dirname(__file__)), 'app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 会话配置
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
# 邮件配置
MAIL_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.googlemail.com'
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') or True
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER')
class DevelopmentConfig(Config):
DEBUG = True
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
WTF_CSRF_ENABLED = False
class ProductionConfig(Config):
DEBUG = False
# 生产环境可以使用更安全的数据库
# SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
3.3 应用初始化
创建app/__init__.py
:
python
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_bcrypt import Bcrypt
from config import config
# 初始化扩展
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.login_message = '请先登录'
bcrypt = Bcrypt()
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
# 初始化扩展
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
bcrypt.init_app(app)
# 注册蓝图
from app.routes.auth import auth as auth_blueprint
from app.routes.main import main as main_blueprint
from app.routes.transactions import transactions as transactions_blueprint
from app.routes.categories import categories as categories_blueprint
from app.routes.reports import reports as reports_blueprint
from app.routes.budgets import budgets as budgets_blueprint
app.register_blueprint(auth_blueprint)
app.register_blueprint(main_blueprint)
app.register_blueprint(transactions_blueprint)
app.register_blueprint(categories_blueprint)
app.register_blueprint(reports_blueprint)
app.register_blueprint(budgets_blueprint)
return app
3.4 数据库模型
创建app/models.py
:
python
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db, login_manager
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True)
email = db.Column(db.String(120), unique=True, index=True)
password_hash = db.Column(db.String(128))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 关系
transactions = db.relationship('Transaction', backref='user', lazy='dynamic')
categories = db.relationship('Category', backref='user', lazy='dynamic')
budgets = db.relationship('Budget', backref='user', lazy='dynamic')
@property
def password(self):
raise AttributeError('密码不是可读属性')
@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), index=True)
type = db.Column(db.String(10)) # 'income' 或 'expense'
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# 关系
transactions = db.relationship('Transaction', backref='category', lazy='dynamic')
budgets = db.relationship('Budget', backref='category', lazy='dynamic')
def __repr__(self):
return f'<Category {self.name}>'
class Transaction(db.Model):
__tablename__ = 'transactions'
id = db.Column(db.Integer, primary_key=True)
amount = db.Column(db.Float)
date = db.Column(db.Date, index=True)
description = db.Column(db.String(255))
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'))
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<Transaction {self.amount}>'
class Budget(db.Model):
__tablename__ = 'budgets'
id = db.Column(db.Integer, primary_key=True)
amount = db.Column(db.Float)
month = db.Column(db.Integer)
year = db.Column(db.Integer)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
def __repr__(self):
return f'<Budget {self.amount}>'
3.5 表单定义
创建app/forms.py
:
python
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, FloatField, SelectField, TextAreaField, DateField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
from app.models import User
class RegistrationForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(min=2, max=20)])
email = StringField('电子邮件', validators=[DataRequired(), Email()])
password = PasswordField('密码', validators=[DataRequired()])
confirm_password = PasswordField('确认密码', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('注册')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('该用户名已被使用')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('该电子邮件已被注册')
class LoginForm(FlaskForm):
email = StringField('电子邮件', validators=[DataRequired(), Email()])
password = PasswordField('密码', validators=[DataRequired()])
submit = SubmitField('登录')
class CategoryForm(FlaskForm):
name = StringField('名称', validators=[DataRequired(), Length(max=64)])
type = SelectField('类型', choices=[('income', '收入'), ('expense', '支出')], validators=[DataRequired()])
submit = SubmitField('保存')
class TransactionForm(FlaskForm):
amount = FloatField('金额', validators=[DataRequired()])
date = DateField('日期', format='%Y-%m-%d', validators=[DataRequired()])
description = TextAreaField('描述', validators=[Length(max=255)])
category_id = SelectField('分类', coerce=int, validators=[DataRequired()])
submit = SubmitField('保存')
class BudgetForm(FlaskForm):
amount = FloatField('金额', validators=[DataRequired()])
month = SelectField('月份', choices=[(i, i) for i in range(1, 13)], coerce=int, validators=[DataRequired()])
year = SelectField('年份', coerce=int, validators=[DataRequired()])
category_id = SelectField('分类', coerce=int)
submit = SubmitField('保存')
def __init__(self, *args, **kwargs):
super(BudgetForm, self).__init__(*args, **kwargs)
# 动态设置年份选项
import datetime
current_year = datetime.datetime.now().year
self.year.choices = [(i, i) for i in range(current_year - 5, current_year + 6)]
3.6 路由和视图函数
主页路由
创建app/routes/main.py
:
python
from flask import Blueprint, render_template
from flask_login import login_required, current_user
from app.models import Transaction, Category, Budget
from sqlalchemy import func
from app import db
import datetime
main = Blueprint('main', __name__)
@main.route('/')
def index():
if current_user.is_authenticated:
return render_template('index.html')
return render_template('landing.html')
@main.route('/dashboard')
@login_required
def dashboard():
# 获取当前月份的交易
today = datetime.date.today()
start_date = datetime.date(today.year, today.month, 1)
end_date = datetime.date(today.year if today.month < 12 else today.year + 1,
(today.month + 1) if today.month < 12 else 1, 1)
# 收入和支出总额
income = db.session.query(func.sum(Transaction.amount)).\
join(Category).filter(Category.type == 'income',
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date).scalar() or 0
expense = db.session.query(func.sum(Transaction.amount)).\
join(Category).filter(Category.type == 'expense',
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date).scalar() or 0
# 最近的交易
recent_transactions = Transaction.query.\
filter_by(user_id=current_user.id).\
order_by(Transaction.date.desc()).\
limit(5).all()
# 预算使用情况
budgets = Budget.query.filter_by(
user_id=current_user.id,
month=today.month,
year=today.year
).all()
# 计算预算使用百分比
budget_usage = []
for budget in budgets:
if budget.category_id:
# 特定分类的预算
spent = db.session.query(func.sum(Transaction.amount)).\
filter(Transaction.category_id == budget.category_id,
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date).scalar() or 0
category_name = Category.query.get(budget.category_id).name
budget_usage.append({
'category': category_name,
'budget': budget.amount,
'spent': spent,
'percentage': min(100, int(spent / budget.amount * 100)) if budget.amount > 0 else 0
})
else:
# 总体预算
budget_usage.append({
'category': '总计',
'budget': budget.amount,
'spent': expense,
'percentage': min(100, int(expense / budget.amount * 100)) if budget.amount > 0 else 0
})
return render_template('dashboard.html',
income=income,
expense=expense,
balance=income-expense,
recent_transactions=recent_transactions,
budget_usage=budget_usage)
认证路由
创建app/routes/auth.py
:
python
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.models import User
from app.forms import RegistrationForm, LoginForm
auth = Blueprint('auth', __name__)
@auth.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data,
email=form.email.data)
user.password = form.password.data
db.session.add(user)
db.session.commit()
flash('注册成功!现在您可以登录了。', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
@auth.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user and user.verify_password(form.password.data):
login_user(user)
next_page = request.args.get('next')
return redirect(next_page or url_for('main.dashboard'))
else:
flash('登录失败。请检查电子邮件和密码。', 'danger')
return render_template('auth/login.html', form=form)
@auth.route('/logout')
@login_required
def logout():
logout_user()
flash('您已成功注销。', 'success')
return redirect(url_for('main.index'))
@auth.route('/profile')
@login_required
def profile():
return render_template('auth/profile.html')
交易路由
创建app/routes/transactions.py
:
python
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import db
from app.models import Transaction, Category
from app.forms import TransactionForm
from sqlalchemy import desc
import datetime
transactions = Blueprint('transactions', __name__)
@transactions.route('/transactions')
@login_required
def index():
# 获取筛选参数
category_id = request.args.get('category_id', type=int)
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
transaction_type = request.args.get('type') # 'income' 或 'expense'
# 构建查询
query = Transaction.query.filter_by(user_id=current_user.id)
if category_id:
query = query.filter_by(category_id=category_id)
if start_date:
query = query.filter(Transaction.date >= datetime.datetime.strptime(start_date, '%Y-%m-%d').date())
if end_date:
query = query.filter(Transaction.date <= datetime.datetime.strptime(end_date, '%Y-%m-%d').date())
if transaction_type:
query = query.join(Category).filter(Category.type == transaction_type)
# 排序
sort_by = request.args.get('sort_by', 'date')
sort_order = request.args.get('sort_order', 'desc')
if sort_by == 'amount':
query = query.order_by(desc(Transaction.amount) if sort_order == 'desc' else Transaction.amount)
elif sort_by == 'date':
query = query.order_by(desc(Transaction.date) if sort_order == 'desc' else Transaction.date)
else:
query = query.order_by(desc(Transaction.date))
# 分页
page = request.args.get('page', 1, type=int)
per_page = 20
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
transactions_list = pagination.items
# 获取所有分类,用于筛选
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('transactions/index.html',
transactions=transactions_list,
pagination=pagination,
categories=categories)
@transactions.route('/transactions/create', methods=['GET', 'POST'])
@login_required
def create():
form = TransactionForm()
# 动态设置分类选项
form.category_id.choices = [(c.id, c.name) for c in
Category.query.filter_by(user_id=current_user.id).all()]
if form.validate_on_submit():
transaction = Transaction(
amount=form.amount.data,
date=form.date.data,
description=form.description.data,
category_id=form.category_id.data,
user_id=current_user.id
)
db.session.add(transaction)
db.session.commit()
flash('交易已添加!', 'success')
return redirect(url_for('transactions.index'))
return render_template('transactions/create.html', form=form)
@transactions.route('/transactions/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
transaction = Transaction.query.get_or_404(id)
# 确保用户只能编辑自己的交易
if transaction.user_id != current_user.id:
flash('您无权编辑此交易。', 'danger')
return redirect(url_for('transactions.index'))
form = TransactionForm()
# 动态设置分类选项
form.category_id.choices = [(c.id, c.name) for c in
Category.query.filter_by(user_id=current_user.id).all()]
if form.validate_on_submit():
transaction.amount = form.amount.data
transaction.date = form.date.data
transaction.description = form.description.data
transaction.category_id = form.category_id.data
db.session.commit()
flash('交易已更新!', 'success')
return redirect(url_for('transactions.index'))
# 填充表单
if request.method == 'GET':
form.amount.data = transaction.amount
form.date.data = transaction.date
form.description.data = transaction.description
form.category_id.data = transaction.category_id
return render_template('transactions/edit.html', form=form, transaction=transaction)
@transactions.route('/transactions/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
transaction = Transaction.query.get_or_404(id)
# 确保用户只能删除自己的交易
if transaction.user_id != current_user.id:
flash('您无权删除此交易。', 'danger')
return redirect(url_for('transactions.index'))
db.session.delete(transaction)
db.session.commit()
flash('交易已删除!', 'success')
return redirect(url_for('transactions.index'))
分类路由
创建app/routes/categories.py
:
python
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import db
from app.models import Category
from app.forms import CategoryForm
categories = Blueprint('categories', __name__)
@categories.route('/categories')
@login_required
def index():
categories_list = Category.query.filter_by(user_id=current_user.id).all()
return render_template('categories/index.html', categories=categories_list)
@categories.route('/categories/create', methods=['GET', 'POST'])
@login_required
def create():
form = CategoryForm()
if form.validate_on_submit():
category = Category(
name=form.name.data,
type=form.type.data,
user_id=current_user.id
)
db.session.add(category)
db.session.commit()
flash('分类已添加!', 'success')
return redirect(url_for('categories.index'))
return render_template('categories/create.html', form=form)
@categories.route('/categories/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
category = Category.query.get_or_404(id)
# 确保用户只能编辑自己的分类
if category.user_id != current_user.id:
flash('您无权编辑此分类。', 'danger')
return redirect(url_for('categories.index'))
form = CategoryForm()
if form.validate_on_submit():
category.name = form.name.data
category.type = form.type.data
db.session.commit()
flash('分类已更新!', 'success')
return redirect(url_for('categories.index'))
# 填充表单
if request.method == 'GET':
form.name.data = category.name
form.type.data = category.type
return render_template('categories/edit.html', form=form, category=category)
@categories.route('/categories/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
category = Category.query.get_or_404(id)
# 确保用户只能删除自己的分类
if category.user_id != current_user.id:
flash('您无权删除此分类。', 'danger')
return redirect(url_for('categories.index'))
# 检查是否有关联的交易
if category.transactions.count() > 0:
flash('无法删除此分类,因为它已被交易使用。', 'danger')
return redirect(url_for('categories.index'))
db.session.delete(category)
db.session.commit()
flash('分类已删除!', 'success')
return redirect(url_for('categories.index'))
报表路由
创建app/routes/reports.py
:
python
from flask import Blueprint, render_template, request, jsonify, send_file
from flask_login import login_required, current_user
from app import db
from app.models import Transaction, Category
from sqlalchemy import func, extract
import datetime
import io
import csv
reports = Blueprint('reports', __name__)
@reports.route('/reports')
@login_required
def index():
return render_template('reports/index.html')
@reports.route('/reports/monthly')
@login_required
def monthly():
# 获取年份和月份参数
year = request.args.get('year', datetime.datetime.now().year, type=int)
month = request.args.get('month', datetime.datetime.now().month, type=int)
# 计算日期范围
start_date = datetime.date(year, month, 1)
if month == 12:
end_date = datetime.date(year + 1, 1, 1)
else:
end_date = datetime.date(year, month + 1, 1)
# 获取收入和支出
income_by_category = db.session.query(
Category.name,
func.sum(Transaction.amount).label('total')
).join(Transaction).filter(
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date,
Category.type == 'income'
).group_by(Category.name).all()
expense_by_category = db.session.query(
Category.name,
func.sum(Transaction.amount).label('total')
).join(Transaction).filter(
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date,
Category.type == 'expense'
).group_by(Category.name).all()
# 计算总收入和总支出
total_income = sum(item.total for item in income_by_category)
total_expense = sum(item.total for item in expense_by_category)
# 按日期的收入和支出
daily_data = db.session.query(
Transaction.date,
Category.type,
func.sum(Transaction.amount).label('total')
).join(Category).filter(
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date
).group_by(Transaction.date, Category.type).all()
# 处理数据用于图表
dates = sorted(set(item.date for item in daily_data))
daily_income = {date: 0 for date in dates}
daily_expense = {date: 0 for date in dates}
for item in daily_data:
if item.type == 'income':
daily_income[item.date] = item.total
else:
daily_expense[item.date] = item.total
chart_dates = [date.strftime('%Y-%m-%d') for date in dates]
chart_income = [daily_income[date] for date in dates]
chart_expense = [daily_expense[date] for date in dates]
return render_template('reports/monthly.html',
year=year,
month=month,
income_by_category=income_by_category,
expense_by_category=expense_by_category,
total_income=total_income,
total_expense=total_expense,
chart_dates=chart_dates,
chart_income=chart_income,
chart_expense=chart_expense)
@reports.route('/reports/annual')
@login_required
def annual():
# 获取年份参数
year = request.args.get('year', datetime.datetime.now().year, type=int)
# 按月份和类型的收入和支出
monthly_data = db.session.query(
extract('month', Transaction.date).label('month'),
Category.type,
func.sum(Transaction.amount).label('total')
).join(Category).filter(
Transaction.user_id == current_user.id,
extract('year', Transaction.date) == year
).group_by('month', Category.type).all()
# 处理数据用于图表
months = list(range(1, 13))
monthly_income = {month: 0 for month in months}
monthly_expense = {month: 0 for month in months}
for item in monthly_data:
if item.type == 'income':
monthly_income[item.month] = item.total
else:
monthly_expense[item.month] = item.total
chart_months = [f"{year}-{month:02d}" for month in months]
chart_income = [monthly_income[month] for month in months]
chart_expense = [monthly_expense[month] for month in months]
# 计算年度总收入和总支出
total_income = sum(monthly_income.values())
total_expense = sum(monthly_expense.values())
# 按类别的年度收入和支出
income_by_category = db.session.query(
Category.name,
func.sum(Transaction.amount).label('total')
).join(Transaction).filter(
Transaction.user_id == current_user.id,
extract('year', Transaction.date) == year,
Category.type == 'income'
).group_by(Category.name).all()
expense_by_category = db.session.query(
Category.name,
func.sum(Transaction.amount).label('total')
).join(Transaction).filter(
Transaction.user_id == current_user.id,
extract('year', Transaction.date) == year,
Category.type == 'expense'
).group_by(Category.name).all()
return render_template('reports/annual.html',
year=year,
monthly_income=monthly_income,
monthly_expense=monthly_expense,
chart_months=chart_months,
chart_income=chart_income,
chart_expense=chart_expense,
total_income=total_income,
total_expense=total_expense,
income_by_category=income_by_category,
expense_by_category=expense_by_category)
@reports.route('/reports/export')
@login_required
def export():
# 获取日期范围
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
if start_date:
start_date = datetime.datetime.strptime(start_date, '%Y-%m-%d').date()
else:
# 默认为当年开始
start_date = datetime.date(datetime.date.today().year, 1, 1)
if end_date:
end_date = datetime.datetime.strptime(end_date, '%Y-%m-%d').date()
else:
# 默认为今天
end_date = datetime.date.today()
# 查询交易
transactions = Transaction.query.filter(
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date <= end_date
).order_by(Transaction.date).all()
# 创建CSV文件
output = io.StringIO()
writer = csv.writer(output)
# 写入标题行
writer.writerow(['日期', '金额', '类别', '类型', '描述'])
# 写入数据行
for transaction in transactions:
category = Category.query.get(transaction.category_id)
writer.writerow([
transaction.date.strftime('%Y-%m-%d'),
transaction.amount,
category.name,
'收入' if category.type == 'income' else '支出',
transaction.description
])
# 准备响应
output.seek(0)
return send_file(
io.BytesIO(output.getvalue().encode('utf-8-sig')),
mimetype='text/csv',
as_attachment=True,
download_name=f'transactions_{start_date}_{end_date}.csv'
)
预算路由
创建app/routes/budgets.py
:
python
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import db
from app.models import Budget, Category, Transaction
from app.forms import BudgetForm
from sqlalchemy import func
import datetime
budgets = Blueprint('budgets', __name__)
@budgets.route('/budgets')
@login_required
def index():
# 获取当前月份和年份
today = datetime.date.today()
month = request.args.get('month', today.month, type=int)
year = request.args.get('year', today.year, type=int)
# 查询预算
budgets_list = Budget.query.filter_by(
user_id=current_user.id,
month=month,
year=year
).all()
# 计算每个预算的使用情况
start_date = datetime.date(year, month, 1)
if month == 12:
end_date = datetime.date(year + 1, 1, 1)
else:
end_date = datetime.date(year, month + 1, 1)
budget_usage = []
for budget in budgets_list:
if budget.category_id:
# 特定分类的预算
spent = db.session.query(func.sum(Transaction.amount)).\
filter(Transaction.category_id == budget.category_id,
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date).scalar() or 0
category = Category.query.get(budget.category_id)
budget_usage.append({
'id': budget.id,
'category': category.name if category else '未知',
'budget': budget.amount,
'spent': spent,
'remaining': budget.amount - spent,
'percentage': min(100, int(spent / budget.amount * 100)) if budget.amount > 0 else 0
})
else:
# 总体预算
spent = db.session.query(func.sum(Transaction.amount)).\
join(Category).filter(Category.type == 'expense',
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date).scalar() or 0
budget_usage.append({
'id': budget.id,
'category': '总支出',
'budget': budget.amount,
'spent': spent,
'remaining': budget.amount - spent,
'percentage': min(100, int(spent / budget.amount * 100)) if budget.amount > 0 else 0
})
return render_template('budgets/index.html',
budgets=budget_usage,
month=month,
year=year)
@budgets.route('/budgets/create', methods=['GET', 'POST'])
@login_required
def create():
form = BudgetForm()
# 动态设置分类选项
categories = Category.query.filter_by(user_id=current_user.id, type='expense').all()
form.category_id.choices = [(0, '总体预算')] + [(c.id, c.name) for c in categories]
if form.validate_on_submit():
# 检查是否已存在相同月份、年份和分类的预算
existing_budget = Budget.query.filter_by(
user_id=current_user.id,
month=form.month.data,
year=form.year.data,
category_id=form.category_id.data if form.category_id.data != 0 else None
).first()
if existing_budget:
flash('该月份已存在此类别的预算。', 'danger')
return redirect(url_for('budgets.create'))
budget = Budget(
amount=form.amount.data,
month=form.month.data,
year=form.year.data,
category_id=form.category_id.data if form.category_id.data != 0 else None,
user_id=current_user.id
)
db.session.add(budget)
db.session.commit()
flash('预算已添加!', 'success')
return redirect(url_for('budgets.index'))
return render_template('budgets/create.html', form=form)
@budgets.route('/budgets/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
budget = Budget.query.get_or_404(id)
# 确保用户只能编辑自己的预算
if budget.user_id != current_user.id:
flash('您无权编辑此预算。', 'danger')
return redirect(url_for('budgets.index'))
form = BudgetForm()
# 动态设置分类选项
categories = Category.query.filter_by(user_id=current_user.id, type='expense').all()
form.category_id.choices = [(0, '总体预算')] + [(c.id, c.name) for c in categories]
if form.validate_on_submit():
budget.amount = form.amount.data
budget.month = form.month.data
budget.year = form.year.data
budget.category_id = form.category_id.data if form.category_id.data != 0 else None
db.session.commit()
flash('预算已更新!', 'success')
return redirect(url_for('budgets.index'))
# 填充表单
if request.method == 'GET':
form.amount.data = budget.amount
form.month.data = budget.month
form.year.data = budget.year
form.category_id.data = budget.category_id if budget.category_id else 0
return render_template('budgets/edit.html', form=form, budget=budget)
@budgets.route('/budgets/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
budget = Budget.query.get_or_404(id)
# 确保用户只能删除自己的预算
if budget.user_id != current_user.id:
flash('您无权删除此预算。', 'danger')
return redirect(url_for('budgets.index'))
db.session.delete(budget)
db.session.commit()
flash('预算已删除!', 'success')
return redirect(url_for('budgets.index'))
3.7 HTML模板
基础模板
创建app/templates/base.html
:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}个人财务管理系统{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<!-- 自定义CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block styles %}{% endblock %}
</head>
<body>
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="{{ url_for('main.index') }}">
<i class="fas fa-wallet"></i> 财务管理
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{% if current_user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.dashboard') }}">
<i class="fas fa-tachometer-alt"></i> 仪表盘
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('transactions.index') }}">
<i class="fas fa-exchange-alt"></i> 交易
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('categories.index') }}">
<i class="fas fa-tags"></i> 分类
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('budgets.index') }}">
<i class="fas fa-chart-pie"></i> 预算
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('reports.index') }}">
<i class="fas fa-chart-bar"></i> 报表
</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
{% if current_user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user"></i> {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">个人资料</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">注销</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">登录</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">注册</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- 主内容 -->
<div class="container mt-4">
<!-- 闪现消息 -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- 页面内容 -->
{% block content %}{% endblock %}
</div>
<!-- 页脚 -->
<footer class="footer mt-5 py-3 bg-light">
<div class="container text-center">
<span class="text-muted">© 2025 个人财务管理系统</span>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<!-- 自定义JS -->
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
仪表盘模板
创建app/templates/dashboard.html
:
html
{% extends 'base.html' %}
{% block title %}仪表盘 - 个人财务管理系统{% endblock %}
{% block content %}
<h1 class="mb-4">仪表盘</h1>
<!-- 财务概览 -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">收入</h5>
<h2 class="card-text">¥{{ "%.2f"|format(income) }}</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card bg-danger text-white">
<div class="card-body">
<h5 class="card-title">支出</h5>
<h2 class="card-text">¥{{ "%.2f"|format(expense) }}</h2>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card {% if balance >= 0 %}bg-primary{% else %}bg-warning{% endif %} text-white">
<div class="card-body">
<h5 class="card-title">结余</h5>
<h2 class="card-text">¥{{ "%.2f"|format(balance) }}</h2>
</div>
</div>
</div>
</div>
<div class="row">
<!-- 最近交易 -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">最近交易</h5>
</div>
<div class="card-body">
{% if recent_transactions %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>日期</th>
<th>分类</th>
<th>金额</th>
</tr>
</thead>
<tbody>
{% for transaction in recent_transactions %}
<tr>
<td>{{ transaction.date.strftime('%Y-%m-%d') }}</td>
<td>{{ transaction.category.name }}</td>
<td class="{% if transaction.category.type == 'income' %}text-success{% else %}text-danger{% endif %}">
{% if transaction.category.type == 'income' %}+{% else %}-{% endif %}
¥{{ "%.2f"|format(transaction.amount) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<a href="{{ url_for('transactions.index') }}" class="btn btn-sm btn-primary">查看所有交易</a>
{% else %}
<p class="text-muted">暂无交易记录</p>
<a href="{{ url_for('transactions.create') }}" class="btn btn-sm btn-primary">添加交易</a>
{% endif %}
</div>
</div>
</div>
<!-- 预算使用情况 -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">预算使用情况</h5>
</div>
<div class="card-body">
{% if budget_usage %}
{% for item in budget_usage %}
<div class="mb-3">
<div class="d-flex justify-content-between">
<span>{{ item.category }}</span>
<span>¥{{ "%.2f"|format(item.spent) }} / ¥{{ "%.2f"|format(item.budget) }}</span>
</div>
<div class="progress">
<div class="progress-bar {% if item.percentage >= 100 %}bg-danger{% elif item.percentage >= 80 %}bg-warning{% else %}bg-success{% endif %}"
role="progressbar"
style="width: {{ item.percentage }}%"
aria-valuenow="{{ item.percentage }}"
aria-valuemin="0"
aria-valuemax="100">
{{ item.percentage }}%
</div>
</div>
</div>
{% endfor %}
<a href="{{ url_for('budgets.index') }}" class="btn btn-sm btn-primary">管理预算</a>
{% else %}
<p class="text-muted">暂无预算设置</p>
<a href="{{ url_for('budgets.create') }}" class="btn btn-sm btn-primary">设置预算</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
3.8 静态文件
创建app/static/css/style.css
:
css
/* 全局样式 */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.container {
flex: 1;
}
/* 导航栏 */
.navbar-brand i {
margin-right: 5px;
}
/* 卡片样式 */
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
margin-bottom: 1.5rem;
}
.card-header {
background-color: #f8f9fa;
}
/* 表单样式 */
.form-group {
margin-bottom: 1rem;
}
/* 按钮样式 */
.btn-action {
margin-right: 5px;
}
/* 表格样式 */
.table-responsive {
margin-bottom: 1rem;
}
/* 页脚 */
.footer {
margin-top: auto;
}
/* 图表容器 */
.chart-container {
position: relative;
height: 300px;
margin-bottom: 2rem;
}
创建app/static/js/script.js
:
javascript
// 通用函数
document.addEventListener('DOMContentLoaded', function() {
// 自动关闭警告消息
setTimeout(function() {
const alerts = document.querySelectorAll('.alert');
alerts.forEach(function(alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
});
}, 5000);
// 日期选择器初始化
const dateInputs = document.querySelectorAll('input[type="date"]');
dateInputs.forEach(function(input) {
if (!input.value) {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
input.value = `${year}-${month}-${day}`;
}
});
// 确认删除
const deleteButtons = document.querySelectorAll('.btn-delete');
deleteButtons.forEach(function(button) {
button.addEventListener('click', function(e) {
if (!confirm('确定要删除吗?此操作无法撤销。')) {
e.preventDefault();
}
});
});
});
// 创建饼图
function createPieChart(elementId, labels, data, backgroundColor) {
const ctx = document.getElementById(elementId).getContext('2d');
new Chart(ctx, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: backgroundColor,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right'
}
}
}
});
}
// 创建折线图
function createLineChart(elementId, labels, datasets) {
const ctx = document.getElementById(elementId).getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
3.9 应用入口
创建run.py
:
python
from app import create_app, db
from app.models import User, Category, Transaction, Budget
app = create_app()
@app.shell_context_processor
def make_shell_context():
return {
'db': db,
'User': User,
'Category': Category,
'Transaction': Transaction,
'Budget': Budget
}
if __name__ == '__main__':
app.run(debug=True)
4. 测试
4.1 单元测试
创建tests/test_models.py
:
python
import unittest
from app import create_app, db
from app.models import User, Category, Transaction, Budget
import datetime
class ModelsTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
# 创建测试用户
self.user = User(username='testuser', email='test@example.com')
self.user.password = 'password'
db.session.add(self.user)
db.session.commit()
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
def test_user_password(self):
self.assertTrue(self.user.verify_password('password'))
self.assertFalse(self.user.verify_password('wrong_password'))
def test_category_creation(self):
category = Category(name='Food', type='expense', user=self.user)
db.session.add(category)
db.session.commit()
self.assertEqual(category.name, 'Food')
self.assertEqual(category.type, 'expense')
self.assertEqual(category.user, self.user)
def test_transaction_creation(self):
category = Category(name='Food', type='expense', user=self.user)
db.session.add(category)
db.session.commit()
transaction = Transaction(
amount=50.0,
date=datetime.date.today(),
description='Groceries',
category=category,
user=self.user
)
db.session.add(transaction)
db.session.commit()
self.assertEqual(transaction.amount, 50.0)
self.assertEqual(transaction.description, 'Groceries')
self.assertEqual(transaction.category, category)
self.assertEqual(transaction.user, self.user)
def test_budget_creation(self):
category = Category(name='Food', type='expense', user=self.user)
db.session.add(category)
db.session.commit()
budget = Budget(
amount=500.0,
month=datetime.date.today().month,
year=datetime.date.today().year,
category=category,
user=self.user
)
db.session.add(budget)
db.session.commit()
self.assertEqual(budget.amount, 500.0)
self.assertEqual(budget.category, category)
self.assertEqual(budget.user, self.user)
创建tests/test_routes.py
:
python
import unittest
from flask import url_for
from app import create_app, db
from app.models import User, Category, Transaction
import datetime
class RoutesTestCase(unittest.TestCase):
def setUp(self):
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
# 创建测试客户端
self.client = self.app.test_client(use_cookies=True)
# 创建测试用户
self.user = User(username='testuser', email='test@example.com')
self.user.password = 'password'
db.session.add(self.user)
db.session.commit()
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
def login(self):
return self.client.post(url_for('auth.login'), data={
'email': 'test@example.com',
'password': 'password'
}, follow_redirects=True)
def test_home_page(self):
response = self.client.get(url_for('main.index'))
self.assertEqual(response.status_code, 200)
def test_login_and_logout(self):
# 登录
response = self.login()
self.assertEqual(response.status_code, 200)
# 注销
response = self.client.get(url_for('auth.logout'), follow_redirects=True)
self.assertEqual(response.status_code, 200)
def test_dashboard_access(self):
# 未登录时访问仪表盘
response = self.client.get(url_for('main.dashboard'), follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn(b'login', response.data)
# 登录后访问仪表盘
self.login()
response = self.client.get(url_for('main.dashboard'))
self.assertEqual(response.status_code, 200)
self.assertIn(b'Dashboard', response.data)
def test_category_management(self):
self.login()
# 创建分类
response = self.client.post(url_for('categories.create'), data={
'name': 'Food',
'type': 'expense'
}, follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn(b'Food', response.data)
# 验证分类已创建
category = Category.query.filter_by(name='Food').first()
self.assertIsNotNone(category)
self.assertEqual(category.name, 'Food')
self.assertEqual(category.type, 'expense')
def test_transaction_management(self):
self.login()
# 创建分类
category = Category(name='Food', type='expense', user=self.user)
db.session.add(category)
db.session.commit()
# 创建交易
response = self.client.post(url_for('transactions.create'), data={
'amount': '50.0',
'date': datetime.date.today().strftime('%Y-%m-%d'),
'description': 'Groceries',
'category_id': category.id
}, follow_redirects=True)
self.assertEqual(response.status_code, 200)
# 验证交易已创建
transaction = Transaction.query.filter_by(description='Groceries').first()
self.assertIsNotNone(transaction)
self.assertEqual(transaction.amount, 50.0)
self.assertEqual(transaction.category, category)
4.2 运行测试
bash
# 运行所有测试
python -m unittest discover tests
# 运行特定测试文件
python -m unittest tests/test_models.py
5. 部署
5.1 准备生产环境
- 创建
requirements.txt
:
bash
pip freeze > requirements.txt
- 配置环境变量:
bash
export FLASK_APP=run.py
export FLASK_ENV=production
export SECRET_KEY=your-secret-key
5.2 使用Gunicorn和Nginx部署
- 安装Gunicorn:
bash
pip install gunicorn
- 创建Gunicorn配置文件
gunicorn_config.py
:
python
bind = "127.0.0.1:8000"
workers = 4
worker_class = "gevent"
timeout = 120
- 启动Gunicorn:
bash
gunicorn -c gunicorn_config.py run:app
- Nginx配置:
nginx
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /static {
alias /path/to/your/app/static;
}
}
5.3 使用Docker部署
- 创建
Dockerfile
:
dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV FLASK_APP=run.py
ENV FLASK_ENV=production
EXPOSE 5000
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "run:app"]
- 创建
docker-compose.yml
:
yaml
version: '3'
services:
web:
build: .
ports:
- "5000:5000"
environment:
- SECRET_KEY=your-secret-key
- DATABASE_URL=sqlite:///app.db
volumes:
- ./app.db:/app/app.db
- 构建和运行Docker容器:
bash
docker-compose up -d
6. 项目扩展
完成基本功能后,可以考虑以下扩展:
- 多币种支持:添加不同货币的支持和汇率转换
- 定期交易:支持设置定期重复的交易
- 数据导入/导出:支持从其他财务软件导入数据
- 移动应用:开发配套的移动应用
- 投资跟踪:添加投资组合管理功能
- 目标设置:设置财务目标并跟踪进度
- 通知系统:预算超支提醒、账单到期提醒等
7. 今日总结
在今天的学习中,我们完成了一个完整的个人财务管理系统,涵盖了以下内容:
- 项目规划:需求分析、系统架构设计、数据库设计
- 后端开发:使用Flask框架实现MVC架构
- 前端开发:使用Bootstrap和Chart.js创建响应式界面和数据可视化
- 数据库操作:使用SQLAlchemy ORM进行数据库操作
- 用户认证:实现用户注册、登录和权限控制
- 测试:编写单元测试和集成测试
- 部署:使用Gunicorn、Nginx和Docker部署应用
通过这个项目,我们将前几天学习的Python知识应用到实际开发中,包括Web开发、数据处理、测试和部署等方面。这种端到端的项目实践有助于巩固所学知识,并了解如何将不同技术整合起来构建完整的应用。
8. 学习总结
经过两周的Python学习,我们已经从基础知识到实际项目开发,系统地学习了Python编程。以下是这两周学习的总结:
第一周:Python基础
- Python基础入门:语法、数据类型、变量、运算符
- Python数据结构:列表、元组、字典、集合
- Python控制流与函数:条件语句、循环、函数定义和调用
- Python面向对象编程:类、对象、继承、多态
- Python模块与包:模块导入、包管理、虚拟环境
- Python文件操作与异常处理:文件读写、异常捕获
- Python高级特性:装饰器、生成器、迭代器、上下文管理器
第二周:Python应用开发
- Python Web开发基础:Flask框架
- Python Web开发进阶:Django框架
- Python数据分析与可视化:NumPy、pandas、Matplotlib、Seaborn
- Python测试与调试:unittest、pytest、调试技术、性能分析
- Python项目实战:个人财务管理系统
通过这两周的学习,我们已经具备了使用Python进行实际开发的能力。Python的简洁语法和丰富的生态系统使其成为一种强大而灵活的编程语言,适用于Web开发、数据分析、人工智能等多个领域。
希望这个学习计划对您从Java转向Python的旅程有所帮助。继续实践和探索,您将能够充分发挥Python的潜力,并在各种项目中应用它。
Python项目实战
1. 项目概述
在前几天的学习中,我们已经掌握了Python的基础知识、Web开发框架、数据分析与可视化以及测试与调试技术。今天,我们将把这些知识整合起来,构建一个完整的实际项目。
项目选择:个人财务管理系统
我们将构建一个个人财务管理系统,具有以下功能:
- 用户认证:注册、登录和注销
- 交易管理:添加、编辑、删除和查看交易记录
- 分类管理:创建和管理交易分类
- 数据可视化:展示收入和支出的图表
- 报表生成:生成财务报表
- 预算设置:设置和跟踪预算
这个项目将使用以下技术:
- 后端:Flask框架
- 数据库:SQLite(简单部署)或PostgreSQL(生产环境)
- ORM:SQLAlchemy
- 前端:HTML、CSS、JavaScript和Bootstrap
- 数据可视化:Chart.js
- 测试:pytest
2. 项目规划
需求分析
在开始编码之前,我们需要明确项目的需求:
功能需求
-
用户管理
- 用户注册(用户名、电子邮件、密码)
- 用户登录和注销
- 用户个人资料管理
-
交易管理
- 添加交易(日期、金额、类别、描述)
- 编辑和删除交易
- 查看交易列表(支持筛选和排序)
-
分类管理
- 创建交易分类(名称、类型:收入/支出)
- 编辑和删除分类
- 查看分类列表
-
数据可视化
- 按类别的收入和支出饼图
- 按时间的收入和支出折线图
- 预算与实际支出的对比图
-
报表
- 月度和年度财务报表
- 导出报表(CSV、PDF)
-
预算
- 设置月度预算(总体和分类)
- 跟踪预算使用情况
非功能需求
- 安全性:密码加密、CSRF保护、输入验证
- 性能:页面加载时间小于2秒
- 可用性:响应式设计,适配移动设备
- 可靠性:数据备份和恢复机制
系统架构
我们将采用经典的MVC(模型-视图-控制器)架构:
- 模型(Model):数据库模型和业务逻辑
- 视图(View):HTML模板和前端代码
- 控制器(Controller):Flask路由和视图函数
数据库设计
我们需要以下数据表:
-
用户(users)
- id: 主键
- username: 用户名
- email: 电子邮件
- password_hash: 密码哈希
- created_at: 创建时间
-
分类(categories)
- id: 主键
- name: 分类名称
- type: 类型(收入/支出)
- user_id: 外键,关联用户
-
交易(transactions)
- id: 主键
- amount: 金额
- date: 日期
- description: 描述
- category_id: 外键,关联分类
- user_id: 外键,关联用户
- created_at: 创建时间
-
预算(budgets)
- id: 主键
- amount: 金额
- month: 月份
- year: 年份
- category_id: 外键,关联分类(可选)
- user_id: 外键,关联用户
项目结构
csharp
finance_app/
├── app/
│ ├── __init__.py # 应用初始化
│ ├── models.py # 数据库模型
│ ├── forms.py # 表单定义
│ ├── routes/ # 路由和视图函数
│ │ ├── __init__.py
│ │ ├── auth.py # 认证相关路由
│ │ ├── main.py # 主页和通用路由
│ │ ├── transactions.py # 交易相关路由
│ │ ├── categories.py # 分类相关路由
│ │ ├── reports.py # 报表相关路由
│ │ └── budgets.py # 预算相关路由
│ ├── static/ # 静态文件
│ │ ├── css/
│ │ ├── js/
│ │ └── img/
│ └── templates/ # HTML模板
│ ├── base.html
│ ├── index.html
│ ├── auth/
│ ├── transactions/
│ ├── categories/
│ ├── reports/
│ └── budgets/
├── migrations/ # 数据库迁移文件
├── tests/ # 测试文件
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_routes.py
│ └── test_forms.py
├── config.py # 配置文件
├── run.py # 应用入口
├── requirements.txt # 依赖列表
└── README.md # 项目说明
3. 项目实现
现在,让我们开始实现这个项目。我们将按照以下步骤进行:
- 设置项目环境
- 实现数据库模型
- 创建用户认证功能
- 实现交易和分类管理
- 添加数据可视化和报表功能
- 实现预算管理
- 编写测试
- 部署应用
3.1 设置项目环境
首先,我们需要创建项目目录并安装必要的依赖:
bash
# 创建项目目录
mkdir finance_app
cd finance_app
# 创建虚拟环境
python -m venv venv
# 激活虚拟环境
# Windows
venv\Scripts\activate
# macOS/Linux
source venv/bin/activate
# 安装依赖
pip install flask flask-sqlalchemy flask-migrate flask-login flask-wtf email-validator flask-bcrypt
pip install pytest pytest-flask
创建基本的项目结构:
bash
mkdir -p app/static/{css,js,img}
mkdir -p app/templates/{auth,transactions,categories,reports,budgets}
mkdir -p app/routes
mkdir tests
3.2 配置文件
创建配置文件config.py
:
python
import os
from datetime import timedelta
class Config:
# 基本配置
SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard-to-guess-string'
# 数据库配置
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
'sqlite:///' + os.path.join(os.path.abspath(os.path.dirname(__file__)), 'app.db')
SQLALCHEMY_TRACK_MODIFICATIONS = False
# 会话配置
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
# 邮件配置
MAIL_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.googlemail.com'
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') or True
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER')
class DevelopmentConfig(Config):
DEBUG = True
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
WTF_CSRF_ENABLED = False
class ProductionConfig(Config):
DEBUG = False
# 生产环境可以使用更安全的数据库
# SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
3.3 应用初始化
创建app/__init__.py
:
python
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_bcrypt import Bcrypt
from config import config
# 初始化扩展
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
login_manager.login_message = '请先登录'
bcrypt = Bcrypt()
def create_app(config_name='default'):
app = Flask(__name__)
app.config.from_object(config[config_name])
# 初始化扩展
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
bcrypt.init_app(app)
# 注册蓝图
from app.routes.auth import auth as auth_blueprint
from app.routes.main import main as main_blueprint
from app.routes.transactions import transactions as transactions_blueprint
from app.routes.categories import categories as categories_blueprint
from app.routes.reports import reports as reports_blueprint
from app.routes.budgets import budgets as budgets_blueprint
app.register_blueprint(auth_blueprint)
app.register_blueprint(main_blueprint)
app.register_blueprint(transactions_blueprint)
app.register_blueprint(categories_blueprint)
app.register_blueprint(reports_blueprint)
app.register_blueprint(budgets_blueprint)
return app
3.4 数据库模型
创建app/models.py
:
python
from datetime import datetime
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db, login_manager
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
class User(UserMixin, db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, index=True)
email = db.Column(db.String(120), unique=True, index=True)
password_hash = db.Column(db.String(128))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# 关系
transactions = db.relationship('Transaction', backref='user', lazy='dynamic')
categories = db.relationship('Category', backref='user', lazy='dynamic')
budgets = db.relationship('Budget', backref='user', lazy='dynamic')
@property
def password(self):
raise AttributeError('密码不是可读属性')
@password.setter
def password(self, password):
self.password_hash = generate_password_hash(password)
def verify_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
class Category(db.Model):
__tablename__ = 'categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), index=True)
type = db.Column(db.String(10)) # 'income' 或 'expense'
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
# 关系
transactions = db.relationship('Transaction', backref='category', lazy='dynamic')
budgets = db.relationship('Budget', backref='category', lazy='dynamic')
def __repr__(self):
return f'<Category {self.name}>'
class Transaction(db.Model):
__tablename__ = 'transactions'
id = db.Column(db.Integer, primary_key=True)
amount = db.Column(db.Float)
date = db.Column(db.Date, index=True)
description = db.Column(db.String(255))
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'))
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f'<Transaction {self.amount}>'
class Budget(db.Model):
__tablename__ = 'budgets'
id = db.Column(db.Integer, primary_key=True)
amount = db.Column(db.Float)
month = db.Column(db.Integer)
year = db.Column(db.Integer)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
def __repr__(self):
return f'<Budget {self.amount}>'
3.5 表单定义
创建app/forms.py
:
python
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, FloatField, SelectField, TextAreaField, DateField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
from app.models import User
class RegistrationForm(FlaskForm):
username = StringField('用户名', validators=[DataRequired(), Length(min=2, max=20)])
email = StringField('电子邮件', validators=[DataRequired(), Email()])
password = PasswordField('密码', validators=[DataRequired()])
confirm_password = PasswordField('确认密码', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('注册')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('该用户名已被使用')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('该电子邮件已被注册')
class LoginForm(FlaskForm):
email = StringField('电子邮件', validators=[DataRequired(), Email()])
password = PasswordField('密码', validators=[DataRequired()])
submit = SubmitField('登录')
class CategoryForm(FlaskForm):
name = StringField('名称', validators=[DataRequired(), Length(max=64)])
type = SelectField('类型', choices=[('income', '收入'), ('expense', '支出')], validators=[DataRequired()])
submit = SubmitField('保存')
class TransactionForm(FlaskForm):
amount = FloatField('金额', validators=[DataRequired()])
date = DateField('日期', format='%Y-%m-%d', validators=[DataRequired()])
description = TextAreaField('描述', validators=[Length(max=255)])
category_id = SelectField('分类', coerce=int, validators=[DataRequired()])
submit = SubmitField('保存')
class BudgetForm(FlaskForm):
amount = FloatField('金额', validators=[DataRequired()])
month = SelectField('月份', choices=[(i, i) for i in range(1, 13)], coerce=int, validators=[DataRequired()])
year = SelectField('年份', coerce=int, validators=[DataRequired()])
category_id = SelectField('分类', coerce=int)
submit = SubmitField('保存')
def __init__(self, *args, **kwargs):
super(BudgetForm, self).__init__(*args, **kwargs)
# 动态设置年份选项
import datetime
current_year = datetime.datetime.now().year
self.year.choices = [(i, i) for i in range(current_year - 5, current_year + 6)]
3.6 路由和视图函数
主页路由
创建app/routes/main.py
:
python
from flask import Blueprint, render_template
from flask_login import login_required, current_user
from app.models import Transaction, Category, Budget
from sqlalchemy import func
from app import db
import datetime
main = Blueprint('main', __name__)
@main.route('/')
def index():
if current_user.is_authenticated:
return render_template('index.html')
return render_template('landing.html')
@main.route('/dashboard')
@login_required
def dashboard():
# 获取当前月份的交易
today = datetime.date.today()
start_date = datetime.date(today.year, today.month, 1)
end_date = datetime.date(today.year if today.month < 12 else today.year + 1,
(today.month + 1) if today.month < 12 else 1, 1)
# 收入和支出总额
income = db.session.query(func.sum(Transaction.amount)).\
join(Category).filter(Category.type == 'income',
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date).scalar() or 0
expense = db.session.query(func.sum(Transaction.amount)).\
join(Category).filter(Category.type == 'expense',
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date).scalar() or 0
# 最近的交易
recent_transactions = Transaction.query.\
filter_by(user_id=current_user.id).\
order_by(Transaction.date.desc()).\
limit(5).all()
# 预算使用情况
budgets = Budget.query.filter_by(
user_id=current_user.id,
month=today.month,
year=today.year
).all()
# 计算预算使用百分比
budget_usage = []
for budget in budgets:
if budget.category_id:
# 特定分类的预算
spent = db.session.query(func.sum(Transaction.amount)).\
filter(Transaction.category_id == budget.category_id,
Transaction.user_id == current_user.id,
Transaction.date >= start_date,
Transaction.date < end_date).scalar() or 0
category_name = Category.query.get(budget.category_id).name
budget_usage.append({
'category': category_name,
'budget': budget.amount,
'spent': spent,
'percentage': min(100, int(spent / budget.amount * 100)) if budget.amount > 0 else 0
})
else:
# 总体预算
budget_usage.append({
'category': '总计',
'budget': budget.amount,
'spent': expense,
'percentage': min(100, int(expense / budget.amount * 100)) if budget.amount > 0 else 0
})
return render_template('dashboard.html',
income=income,
expense=expense,
balance=income-expense,
recent_transactions=recent_transactions,
budget_usage=budget_usage)
认证路由
创建app/routes/auth.py
:
python
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.models import User
from app.forms import RegistrationForm, LoginForm
auth = Blueprint('auth', __name__)
@auth.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data,
email=form.email.data)
user.password = form.password.data
db.session.add(user)
db.session.commit()
flash('注册成功!现在您可以登录了。', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)
@auth.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user and user.verify_password(form.password.data):
login_user(user)
next_page = request.args.get('next')
return redirect(next_page or url_for('main.dashboard'))
else:
flash('登录失败。请检查电子邮件和密码。', 'danger')
return render_template('auth/login.html', form=form)
@auth.route('/logout')
@login_required
def logout():
logout_user()
flash('您已成功注销。', 'success')
return redirect(url_for('main.index'))
@auth.route('/profile')
@login_required
def profile():
return render_template('auth/profile.html')
交易路由
创建app/routes/transactions.py
:
python
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import db
from app.models import Transaction, Category
from app.forms import TransactionForm
from sqlalchemy import desc
import datetime
transactions = Blueprint('transactions', __name__)
@transactions.route('/transactions')
@login_required
def index():
# 获取筛选参数
category_id = request.args.get('category_id', type=int)
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
transaction_type = request.args.get('type') # 'income' 或 'expense'
# 构建查询
query = Transaction.query.filter_by(user_id=current_user.id)
if category_id:
query = query.filter_by(category_id=category_id)
if start_date:
query = query.filter(Transaction.date >= datetime.datetime.strptime(start_date, '%Y-%m-%d').date())
if end_date:
query = query.filter(Transaction.date <= datetime.datetime.strptime(end_date, '%Y-%m-%d').date())
if transaction_type:
query = query.join(Category).filter(Category.type == transaction_type)
# 排序
sort_by = request.args.get('sort_by', 'date')
sort_order = request.args.get('sort_order', 'desc')
if sort_by == 'amount':
query = query.order_by(desc(Transaction.amount) if sort_order == 'desc' else Transaction.amount)
elif sort_by == 'date':
query = query.order_by(desc(Transaction.date) if sort_order == 'desc' else Transaction.date)
else:
query = query.order_by(desc(Transaction.date))
# 分页
page = request.args.get('page', 1, type=int)
per_page = 20
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
transactions_list = pagination.items
# 获取所有分类,用于筛选
categories = Category.query.filter_by(user_id=current_user.id).all()
return render_template('transactions/index.html',
transactions=transactions_list,
pagination=pagination,
categories=categories)
@transactions.route('/transactions/create', methods=['GET', 'POST'])
@login_required
def create():
form = TransactionForm()
# 动态设置分类选项
form.category_id.choices = [(c.id, c.name) for c in
Category.query.filter_by(user_id=current_user.id).all()]
if form.validate_on_submit():
transaction = Transaction(
amount=form.amount.data,
date=form.date.data,
description=form.description.data,
category_id=form.category_id.data,
user_id=current_user.id
)
db.session.add(transaction)
db.session.commit()
flash('交易已添加!', 'success')
return redirect(url_for('transactions.index'))
return render_template('transactions/create.html', form=form)
@transactions.route('/transactions/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
transaction = Transaction.query.get_or_404(id)
# 确保用户只能编辑自己的交易
if transaction.user_id != current_user.id:
flash('您无权编辑此交易。', 'danger')
return redirect(url_for('transactions.index'))
form = TransactionForm()
# 动态设置分类选项
form.category_id.choices = [(c.id, c.name) for c in
Category.query.filter_by(user_id=current_user.id).all()]
if form.validate_on_submit():
transaction.amount = form.amount.data
transaction.date = form.date.data
transaction.description = form.description.data
transaction.category_id = form.category_id.data
db.session.commit()
flash('交易已更新!', 'success')
return redirect(url_for('transactions.index'))
# 填充表单
if request.method == 'GET':
form.amount.data = transaction.amount
form.date.data = transaction.date
form.description.data = transaction.description
form.category_id.data = transaction.category_id
return render_template('transactions/edit.html', form=form, transaction=transaction)
@transactions.route('/transactions/<int:id>/delete', methods=['POST'])
@login_required
def delete(id):
transaction = Transaction.query.get_or_404(id)
# 确保用户只能删除自己的交易
if transaction.user_id != current_user.id:
flash('您无权删除此交易。', 'danger')
return redirect(url_for('transactions.index'))
db.session.delete(transaction)
db.session.commit()
flash('交易已删除!', 'success')
return redirect(url_for('transactions.index'))
分类路由
创建app/routes/categories.py
:
python
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import db
from app.models import Category
from app.forms import CategoryForm
categories = Blueprint('categories', __name__)
@categories.route('/categories')
@login_required
def index():
categories_list = Category.query.filter_by(user_id=current_user.id).all()
return render_template('categories/index.html', categories=categories_list)
@categories.route('/categories/create', methods=['GET', 'POST'])
@login_required
def create():
form = CategoryForm()
if form.validate_on_submit():
category = Category(
name=form.name.data,
type=form.type.data,
user_id=current_user.id
)
db.session.add(category)
db.session.commit()
flash('分类已添加!', 'success')
return redirect(url_for('categories.index'))
return render_template('categories/create.html', form=form)
@categories.route('/categories/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit(id):
category = Category.query.get_or_404(id)
# 确保用户只能编辑自己的分类
if category.user_id != current_user.id:
flash('您无权编辑此分类。', 'danger')
return redirect(url_for('categories.index'))
form = CategoryForm()
if form.validate_on_submit():
category.name = form.name.data
category.type = form.type.data
db.session.commit()
flash('分类已更新!', 'success')
return redirect(url_for('categories.index'))
# 填充表单
if request.method == 'GET':
form.name.data = category.name