commit 2bbf79dcd26da894e64e96cf726d200d444e50b1 Author: Ben Mosley Date: Sun Aug 9 22:36:10 2026 -0500 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81f75cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.pyc +instance/ +info.db +supfin/ +static/uploads/* +!static/uploads/.gitkeep diff --git a/app.py b/app.py new file mode 100644 index 0000000..c6692d0 --- /dev/null +++ b/app.py @@ -0,0 +1,240 @@ +import os +import json +import uuid + +from flask import Flask, render_template, request, redirect, url_for, flash +from flask_sqlalchemy import SQLAlchemy +from dotenv import load_dotenv +from werkzeug.utils import secure_filename +from slugify import slugify + +load_dotenv() + +app = Flask(__name__) + +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///info.db' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads') +app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY', 'dev-secret-key-change-me') + +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +db = SQLAlchemy(app) + +ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + + +def allowed_image(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS + + +def unique_slug(base_title, model): + base = slugify(base_title) + slug = base + suffix = 2 + while model.query.filter_by(slug=slug).first() is not None: + slug = f'{base}-{suffix}' + suffix += 1 + return slug + + +class Goal(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text, nullable=False) + images = db.Column(db.Text, nullable=True) + goal_amount = db.Column(db.Integer, nullable=False) + completed = db.Column(db.Boolean, default=False, nullable=False) + slug = db.Column(db.String(255), nullable=False, unique=True) + + babysteps = db.relationship('BabyStep', backref='goal', lazy=True, cascade='all, delete-orphan') + + @property + def image_list(self): + return json.loads(self.images) if self.images else [] + + @property + def saved_amount(self): + return sum(step.saved_amount for step in self.babysteps) + + @property + def progress_percent(self): + if not self.goal_amount: + return 0 + return min(100, round(self.saved_amount / self.goal_amount * 100)) + + +class BabyStep(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text, nullable=False) + monthly_amount = db.Column(db.Integer, nullable=False) + completed = db.Column(db.Boolean, default=False, nullable=False) + slug = db.Column(db.String(255), nullable=False, unique=True) + goal_id = db.Column(db.Integer, db.ForeignKey('goal.id'), nullable=False) + + payments = db.relationship('Payment', backref='babystep', lazy=True, cascade='all, delete-orphan') + + @property + def saved_amount(self): + return sum(payment.amount for payment in self.payments) + + @property + def progress_percent(self): + if not self.monthly_amount: + return 0 + return min(100, round(self.saved_amount / self.monthly_amount * 100)) + + +class Payment(db.Model): + id = db.Column(db.Integer, primary_key=True) + description = db.Column(db.Text, nullable=False) + amount = db.Column(db.Integer, nullable=False) + received_at = db.Column(db.DateTime, server_default=db.func.now()) + babystep_id = db.Column(db.Integer, db.ForeignKey('baby_step.id'), nullable=False) + + +@app.route('/') +def index(): + goals = Goal.query.order_by(Goal.id.desc()).all() + return render_template('index.html', goals=goals) + + +@app.route('/goals') +def view_goals(): + goals = Goal.query.order_by(Goal.id.desc()).all() + return render_template('goals.html', goals=goals) + + +@app.route('/goals/') +def view_goal(slug): + goal = Goal.query.filter_by(slug=slug).first_or_404() + return render_template('goal_detail.html', goal=goal) + + +@app.route('/babysteps') +def view_babysteps(): + babysteps = BabyStep.query.order_by(BabyStep.id.desc()).all() + return render_template('babysteps.html', babysteps=babysteps) + + +@app.route('/babysteps/') +def view_babystep(slug): + babystep = BabyStep.query.filter_by(slug=slug).first_or_404() + return render_template('babystep_detail.html', babystep=babystep) + + +@app.route('/payments') +def view_payments(): + payments = Payment.query.order_by(Payment.id.desc()).all() + return render_template('payments.html', payments=payments) + + +@app.route('/newgoal', methods=['GET', 'POST']) +def new_goal(): + if request.method == 'POST': + title = request.form.get('title', '').strip() + description = request.form.get('description', '').strip() + goal_amount = request.form.get('goal_amount', type=int) + completed = 'completed' in request.form + images = request.files.getlist('images') + + if not title or goal_amount is None: + flash('Title and a valid goal amount are required.') + return render_template('new_goal.html') + + image_filenames = [] + for image in images: + if image.filename and allowed_image(image.filename): + filename = f'{uuid.uuid4().hex}_{secure_filename(image.filename)}' + image.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) + image_filenames.append(filename) + + goal = Goal( + title=title, + description=description, + images=json.dumps(image_filenames), + goal_amount=goal_amount, + completed=completed, + slug=unique_slug(title, Goal), + ) + + db.session.add(goal) + db.session.commit() + flash(f'Goal "{goal.title}" created.') + return redirect(url_for('view_goal', slug=goal.slug)) + + return render_template('new_goal.html') + + +@app.route('/newbabystep', methods=['GET', 'POST']) +def new_babystep(): + goals = Goal.query.order_by(Goal.title).all() + + if request.method == 'POST': + title = request.form.get('title', '').strip() + description = request.form.get('description', '').strip() + monthly_amount = request.form.get('monthly_amount', type=int) + goal_id = request.form.get('goal_id', type=int) + completed = 'completed' in request.form + + goal = db.session.get(Goal, goal_id) if goal_id else None + + if not title or monthly_amount is None or goal is None: + flash('Title, a valid monthly amount, and a goal are required.') + return render_template('new_babystep.html', goals=goals) + + babystep = BabyStep( + title=title, + description=description, + monthly_amount=monthly_amount, + completed=completed, + slug=unique_slug(title, BabyStep), + goal_id=goal.id, + ) + + db.session.add(babystep) + db.session.commit() + flash(f'Babystep "{babystep.title}" added to "{goal.title}".') + return redirect(url_for('view_babystep', slug=babystep.slug)) + + return render_template('new_babystep.html', goals=goals) + + +@app.route('/newpayment', methods=['GET', 'POST']) +def new_payment(): + babysteps = BabyStep.query.order_by(BabyStep.title).all() + + if request.method == 'POST': + description = request.form.get('description', '').strip() + amount = request.form.get('amount', type=int) + babystep_id = request.form.get('babystep_id', type=int) + + babystep = db.session.get(BabyStep, babystep_id) if babystep_id else None + + if not description or amount is None or babystep is None: + flash('Description, a valid amount, and a babystep are required.') + return render_template('new_payment.html', babysteps=babysteps) + + payment = Payment( + description=description, + amount=amount, + babystep_id=babystep.id, + ) + + db.session.add(payment) + db.session.commit() + flash(f'Payment of {amount} applied to "{babystep.title}".') + return redirect(url_for('view_babystep', slug=babystep.slug)) + + return render_template('new_payment.html', babysteps=babysteps) + + +def init_db(): + with app.app_context(): + db.create_all() + + +if __name__ == '__main__': + init_db() + app.run(debug=False) diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..57ebc04 --- /dev/null +++ b/readme.md @@ -0,0 +1,12 @@ +The User will need to enter: + +Multiple Financial Goals (Each Item will be a Goal_ID) + +Monthly Steps Towards that goal (Baby_Step_ID) + +How much they get from each paycheck (Payment_ID) + + +Payment ID relates to Baby Step ID, which updates each Goal ID depending on how the money is allocated. + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3fdb5fe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask +Flask-SQLAlchemy +python-dotenv +python-slugify diff --git a/static/uploads/.gitkeep b/static/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/templates/babystep_detail.html b/templates/babystep_detail.html new file mode 100644 index 0000000..bc65248 --- /dev/null +++ b/templates/babystep_detail.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}{{ babystep.title }} · Superior Finance{% endblock %} +{% block content %} +

{{ babystep.title }}

+

{{ babystep.description }}

+

Toward goal: {{ babystep.goal.title }}

+ +
+
+ ${{ babystep.saved_amount }} of ${{ babystep.monthly_amount }} / mo + {{ babystep.progress_percent }}% +
+
+
+
+
+ +

Payments applied here

+

+ Add a payment

+ {% if not babystep.payments %} +

No payments logged yet.

+ {% else %} +
    + {% for payment in babystep.payments|sort(attribute='id', reverse=True) %} +
  • {{ payment.description }} — ${{ payment.amount }} ({{ payment.received_at }})
  • + {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/templates/babysteps.html b/templates/babysteps.html new file mode 100644 index 0000000..a1f7503 --- /dev/null +++ b/templates/babysteps.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Babysteps · Superior Finance{% endblock %} +{% block content %} +

Babysteps

+ {% if not babysteps %} +

No babysteps yet. Add one.

+ {% else %} + {% for step in babysteps %} +
+

{{ step.title }}

+

Toward {{ step.goal.title }}

+
+ ${{ step.saved_amount }} of ${{ step.monthly_amount }} / mo + {{ step.progress_percent }}% +
+
+
+
+
+ {% endfor %} + {% endif %} +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..fbbe13c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,164 @@ + + + + + + {% block title %}Superior Finance{% endblock %} + + + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/templates/goal_detail.html b/templates/goal_detail.html new file mode 100644 index 0000000..a4bab35 --- /dev/null +++ b/templates/goal_detail.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}{{ goal.title }} · Superior Finance{% endblock %} +{% block content %} +

{{ goal.title }}

+

{{ goal.description }}

+ + {% if goal.image_list %} +
+ {% for image in goal.image_list %} + + {% endfor %} +
+ {% endif %} + +
+
+ ${{ goal.saved_amount }} of ${{ goal.goal_amount }} + {{ goal.progress_percent }}% +
+
+
+
+
+ +

Babysteps toward this goal

+

+ Add a babystep

+ {% if not goal.babysteps %} +

No babysteps yet.

+ {% else %} + {% for step in goal.babysteps %} +
+

{{ step.title }}

+
+ ${{ step.saved_amount }} of ${{ step.monthly_amount }} / mo + {{ step.progress_percent }}% +
+
+
+
+
+ {% endfor %} + {% endif %} +{% endblock %} diff --git a/templates/goals.html b/templates/goals.html new file mode 100644 index 0000000..252a36a --- /dev/null +++ b/templates/goals.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Goals · Superior Finance{% endblock %} +{% block content %} +

Goals

+ {% if not goals %} +

No goals yet. Create your first goal.

+ {% else %} + {% for goal in goals %} +
+

{{ goal.title }}

+

{{ goal.description }}

+
+ ${{ goal.saved_amount }} of ${{ goal.goal_amount }} + {{ goal.progress_percent }}% +
+
+
+
+
+ {% endfor %} + {% endif %} +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..5b3699b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Dashboard · Superior Finance{% endblock %} +{% block content %} +

Your Goals

+ {% if not goals %} +

No goals yet. Create your first goal.

+ {% else %} + {% for goal in goals %} +
+

{{ goal.title }}

+
+ ${{ goal.saved_amount }} of ${{ goal.goal_amount }} + {{ goal.progress_percent }}% +
+
+
+
+

{{ goal.babysteps|length }} babystep(s)

+
+ {% endfor %} + {% endif %} +{% endblock %} diff --git a/templates/new_babystep.html b/templates/new_babystep.html new file mode 100644 index 0000000..ccf3032 --- /dev/null +++ b/templates/new_babystep.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}New Babystep · Superior Finance{% endblock %} +{% block content %} +

New Babystep

+ {% if not goals %} +

You need a goal before you can add a babystep. Create one first.

+ {% else %} +
+ + + + + + +
+ {% endif %} +{% endblock %} diff --git a/templates/new_goal.html b/templates/new_goal.html new file mode 100644 index 0000000..2d54a09 --- /dev/null +++ b/templates/new_goal.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}New Goal · Superior Finance{% endblock %} +{% block content %} +

New Goal

+
+ + + + + + +
+{% endblock %} diff --git a/templates/new_payment.html b/templates/new_payment.html new file mode 100644 index 0000000..976c707 --- /dev/null +++ b/templates/new_payment.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}New Payment · Superior Finance{% endblock %} +{% block content %} +

New Payment

+ {% if not babysteps %} +

You need a babystep before you can log a payment. Create one first.

+ {% else %} +
+ + + + +
+ {% endif %} +{% endblock %} diff --git a/templates/payments.html b/templates/payments.html new file mode 100644 index 0000000..af1aa43 --- /dev/null +++ b/templates/payments.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block title %}Payments · Superior Finance{% endblock %} +{% block content %} +

Payments

+ {% if not payments %} +

No payments logged yet. Log one.

+ {% else %} +
    + {% for payment in payments %} +
  • + {{ payment.description }} — ${{ payment.amount }} + {{ payment.babystep.title }} ({{ payment.received_at }}) +
  • + {% endfor %} +
+ {% endif %} +{% endblock %}