diff options
41 files changed, 1292 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e86ad5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +build/ +*.egg-info/ +instance/ +venv
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..77b0344 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Expense Tracker + +This is app to manage your finances by the manages your expenses. It also allows users to define their own categories. + +## Installation + +The program is written using flask targeting python 3.11.4 +To build this program, use the following steps + +```bash +pip install . +``` + +It is recommended to use a virtual environment to install the program. + +```bash +python -m venv venv +#POSIX shell(For Linux and Mac) +. venv/bin/activate +``` + +You also need the relevant database drivers installed which can be obtained from pip. +For production, use a WGSI server(like gunicorn) + +## Configuration + +In the instance directory (refer to [docs](https://flask.palletsprojects.com/en/2.3.x/config/#instance-folders) for the directory) there needs to be a config.py file with the configuration keys: + +Some key that are needed: + +- [SECRET_KEY](https://flask.palletsprojects.com/en/2.3.x/config/#SECRET_KEY) + +- [SQLALCHEMY_DATABASE_URI](https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/config/#flask_sqlalchemy.config.SQLALCHEMY_DATABASE_URI) + +## Execution + +The Code can be use for development and testing via a convenient wrapper as shown bellow + +```bash +expense run +``` + +In production its recommended to use WGSI server like gunicorn. +To use with gunicorn run: + +```bash +gunicorn 'expense:create_app()' +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bf2bb04 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "expense" +version = "1.0.0" +description = "An Expense Tracker" +dependencies = [ + "Flask", + "Flask-SQLAlchemy @ git+https://github.com/pamelafox/flask-sqlalchemy.git@mixin-three", + "Flask-WTF", + "Flask-Login" +] + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[project.scripts] +expense = "expense.__main__:cli"
\ No newline at end of file diff --git a/src/expense/__init__.py b/src/expense/__init__.py new file mode 100644 index 0000000..eefdbcf --- /dev/null +++ b/src/expense/__init__.py @@ -0,0 +1,5 @@ +""" +The Flask app for an expense tracker +""" + +from .app import db, create_app, loginManager diff --git a/src/expense/__main__.py b/src/expense/__main__.py new file mode 100644 index 0000000..4de4665 --- /dev/null +++ b/src/expense/__main__.py @@ -0,0 +1,13 @@ +import click +from flask.cli import FlaskGroup +from . import create_app + + +@click.group(cls=FlaskGroup, create_app=create_app) +def cli() -> None: + """The Main CLI so we can run it without using flask""" + pass + + +if __name__ == "__main__": + cli() diff --git a/src/expense/app.py b/src/expense/app.py new file mode 100644 index 0000000..6a7b99b --- /dev/null +++ b/src/expense/app.py @@ -0,0 +1,54 @@ +""" +The app setup. +""" + +import os +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_login import LoginManager +from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass + + +class Base(DeclarativeBase, MappedAsDataclass): + """SQLAlchemy Base model that is serailizeable""" + + pass + + +db = SQLAlchemy(model_class=Base) +loginManager = LoginManager() + + +def create_app(instance_path=None) -> Flask: + """Application Factory for Flask""" + app = Flask(__name__, instance_relative_config=True, instance_path=instance_path) + + try: + os.makedirs(app.instance_path) + except OSError: + pass + + app.config.from_pyfile("config.py") + + db.init_app(app) + loginManager.init_app(app) + + with app.app_context(): + from . import auth + + app.register_blueprint(auth.auth) + + from . import tracker + + app.register_blueprint(tracker.tracker) + app.add_url_rule("/", endpoint="index") + + from . import model + + db.create_all() + + model.populate_table() + + loginManager.login_view = "auth.login" # type: ignore + + return app diff --git a/src/expense/auth/__init__.py b/src/expense/auth/__init__.py new file mode 100644 index 0000000..ebd4e03 --- /dev/null +++ b/src/expense/auth/__init__.py @@ -0,0 +1,5 @@ +""" +The authentication blueprint +""" + +from .blueprint import auth diff --git a/src/expense/auth/blueprint.py b/src/expense/auth/blueprint.py new file mode 100644 index 0000000..4f36c12 --- /dev/null +++ b/src/expense/auth/blueprint.py @@ -0,0 +1,20 @@ +from typing import Optional +from flask import Blueprint + +from ..model import User +from .. import loginManager, db + +auth = Blueprint("auth", __name__, url_prefix="/auth") + + +@loginManager.user_loader +def load_user(id: str) -> Optional[User]: + """ + Callback to load user from id which is a number + """ + if id is not None: + return db.session.scalars(db.select(User).where(User.id == id)).one_or_none() + return None + + +from . import login, logout, register, delete diff --git a/src/expense/auth/delete.py b/src/expense/auth/delete.py new file mode 100644 index 0000000..aaa7477 --- /dev/null +++ b/src/expense/auth/delete.py @@ -0,0 +1,31 @@ +from typing import Union +from flask import redirect, url_for, render_template +from flask_login import current_user, login_required, logout_user +from flask_wtf import FlaskForm +from werkzeug import Response +from wtforms import SubmitField + +from ..model import User +from .blueprint import auth, db + + +class DeleteForm(FlaskForm): + """To Handle Delete the user""" + submit = SubmitField("Delete") + + +@auth.route("/delete", methods=["GET", "POST"]) +@login_required +def delete() -> Union[str, Response]: + """ + Delete the user account. Has both GET and POST + """ + form = DeleteForm() + if form.validate_on_submit(): + user_id = current_user.id # type: ignore + logout_user() + user = db.session.scalars(db.select(User).where(User.id == user_id)).one() + db.session.delete(user) + db.session.commit() + return redirect(url_for("index")) + return render_template("auth/delete.html", form=form) diff --git a/src/expense/auth/login.py b/src/expense/auth/login.py new file mode 100644 index 0000000..00f78eb --- /dev/null +++ b/src/expense/auth/login.py @@ -0,0 +1,63 @@ +from typing import Optional, Union +from flask import ( + redirect, + render_template, + request, + url_for, +) +from flask_login import current_user, login_user + +from flask_wtf import FlaskForm +from werkzeug import Response +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import DataRequired, InputRequired + +from .blueprint import auth +from ..model import User +from .. import db + + +class LoginForm(FlaskForm): + """ + The Login Form. + The validate method has be improved to do validating the user + """ + username = StringField(validators=[DataRequired(), InputRequired()]) + password = PasswordField(validators=[DataRequired(), InputRequired()]) + submit = SubmitField("Log In") + + def validate(self, *args, **kwargs): + if not super().validate(*args, **kwargs): + return False + + username = self.username.data + password = self.password.data + + user = db.session.scalars( + db.select(User).where(User.name == username) + ).one_or_none() + if user is None: + self.username.errors.append("Invalid username") # type: ignore + return False + + if not user.password == password: + self.password.errors.append("Invalid password") # type: ignore + return False + + self.user = user + return True + + +@auth.route("/login", methods=("GET", "POST")) +def login() -> Union[str, Response]: + """ + The view for Loging in the User + """ + if current_user.is_authenticated: # type: ignore + return redirect(request.args.get("next", default=url_for("index"))) + form = LoginForm() + if form.validate_on_submit(): + login_user(form.user) + return redirect(request.args.get("next", default=url_for("index"))) + + return render_template("auth/login.html", form=form) diff --git a/src/expense/auth/logout.py b/src/expense/auth/logout.py new file mode 100644 index 0000000..47fa306 --- /dev/null +++ b/src/expense/auth/logout.py @@ -0,0 +1,14 @@ +from flask import redirect, url_for +from flask_login import login_required, logout_user +from werkzeug import Response + +from .blueprint import auth + +@auth.route("/logout") +@login_required +def logout() -> Response: + """ + Logout user View. + """ + logout_user() + return redirect(url_for("index")) diff --git a/src/expense/auth/password.py b/src/expense/auth/password.py new file mode 100644 index 0000000..0c7090c --- /dev/null +++ b/src/expense/auth/password.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from sqlalchemy import Text, TypeDecorator +from werkzeug.security import check_password_hash, generate_password_hash + + +@dataclass(eq=False) +class PasswordHash: + """ + Generate hash for password storage. It wraps arround the werkzeug.security + module. + """ + + hash: str + + def __eq__(self, candidate) -> bool: + return check_password_hash(self.hash, candidate) + + @classmethod + def new(cls, password: str, method: str = "pbkdf2", salt_length: int = 16): + """Wrapper over generate_password""" + hashed_password = generate_password_hash(password, method, salt_length) + return cls(hashed_password) + + +class Password(TypeDecorator): + """The SQLAlchemy Addapter for PasswordHash""" + + impl = Text + + def process_bind_param(self, value, dialect) -> str | None: + if value is not None: + if isinstance(value, PasswordHash): + return value.hash + raise ValueError("Invalid value type. Expected PasswordHash object.") + return None + + def process_result_value(self, value: str, dialect) -> PasswordHash | None: + if value is not None: + return PasswordHash(value) + return None + + def coerce_compared_value(self, op, value): + if isinstance(value, PasswordHash): + return value.hash + return super().coerce_compared_value(op, value) diff --git a/src/expense/auth/register.py b/src/expense/auth/register.py new file mode 100644 index 0000000..0f43c9f --- /dev/null +++ b/src/expense/auth/register.py @@ -0,0 +1,80 @@ +"""Register User""" +from typing import Union +from flask import ( + redirect, + render_template, + url_for, +) +from flask_login import current_user, login_user + +from flask_wtf import FlaskForm +from werkzeug import Response +from wtforms import StringField, PasswordField, SubmitField +from wtforms.validators import ( + DataRequired, + InputRequired, + Length, + ValidationError, + EqualTo, +) + +from ..model import User, Category + +from .password import PasswordHash + +from .blueprint import auth +from .. import db + + +class RegisterForm(FlaskForm): + """User Registeration form""" + username = StringField(validators=[DataRequired(), InputRequired(), Length(max=64)]) + password = PasswordField( + validators=[ + DataRequired(), + InputRequired(), + Length(min=8, message="Select a stronger password."), + ] + ) + confirm = PasswordField( + "Repeat Password", + validators=[ + DataRequired(), + InputRequired(), + EqualTo("password", message="Passwords must match."), + ], + ) + submit = SubmitField("Register") + + def validate_username(self, field: StringField) -> None: + user = db.session.execute( + db.select(User).where(User.name == field.data) + ).first() + if user is not None: + raise ValidationError("Please use a different username.") + + +@auth.route("/register", methods=("GET", "POST")) +def register() -> Union[str, Response]: + """ + Registering User Route + Support both POST and GET + """ + if current_user.is_authenticated: # type: ignore + return redirect(url_for("index")) + form = RegisterForm() + if form.validate_on_submit(): + user = User(name=form.username.data, password=PasswordHash.new(form.password.data)) # type: ignore + + for cat in ["Misc", "Income"]: + category = db.session.scalars( + db.select(Category).where(Category.name == cat) + ).one() + user.categories.append(category) + + db.session.add(user) + db.session.commit() + login_user(user) + return redirect(url_for("index")) + print(form.confirm.errors) + return render_template("auth/register.html", form=form) diff --git a/src/expense/model.py b/src/expense/model.py new file mode 100644 index 0000000..70d94fd --- /dev/null +++ b/src/expense/model.py @@ -0,0 +1,63 @@ +from typing import List +import datetime + +from flask_login import UserMixin +from sqlalchemy import Column, Float, Integer, String, ForeignKey, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .auth.password import PasswordHash, Password +from . import db + +user_category = db.Table( + "user_category_map", + Column("user_id", Integer, ForeignKey("user.id"), primary_key=True), + Column("category_id", Integer, ForeignKey("category.id"), primary_key=True), +) + +class User(db.Model, UserMixin): + """The table for Username and Password""" + id: Mapped[int] = mapped_column(primary_key=True, init=False) + name: Mapped[str] = mapped_column(String(64), unique=True, comment="The username") + password: Mapped[PasswordHash] = mapped_column(Password(), comment="The password as a hash") + expenses: Mapped[List["Expense"]] = relationship( + back_populates="user", + order_by="Expense.date", + cascade="all, delete", + init=False, + ) + categories: Mapped[List["Category"]] = relationship( + secondary=user_category, init=False + ) + + +class Category(db.Model): + """The List of categories, contains both user defined and predefined""" + id: Mapped[int] = mapped_column(primary_key=True, init=False) + name: Mapped[str] = mapped_column(String(256), nullable=False, unique=True) + + +class Expense(db.Model): + """The list of expenses""" + id: Mapped[int] = mapped_column(primary_key=True, init=False) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), init=False) + user = relationship(User, back_populates="expenses") + date: Mapped[datetime.date] = mapped_column() + amount: Mapped[float] = mapped_column(Float(2)) + category_id: Mapped[int] = mapped_column(ForeignKey("category.id"), init=False) + category: Mapped[Category] = relationship(cascade="all, delete") + description: Mapped[str] = mapped_column(Text(), default="") + + +def populate_table() -> None: + """ + Create the initial entries for the table. + Currently populates the Category table with predefined values + """ + for cat in ["Misc", "Income"]: + if db.session.scalars( + db.select(Category).where(Category.name == cat) + ).one_or_none(): + continue + db.session.add(Category(name=cat)) # type: ignore + + db.session.commit() diff --git a/src/expense/static/script.js b/src/expense/static/script.js new file mode 100644 index 0000000..810dc0e --- /dev/null +++ b/src/expense/static/script.js @@ -0,0 +1,165 @@ +/** + * Category type in Javascript + * @typedef {Object} Category + * @property {Number} id + * @property {string} name + */ + +/** + * Expense Type needed for summary + * @typedef {Object} Expense + * @property {Category} category + * @property {Number} amount + */ + +/*** + * The main function + */ +async function main() { + const data = await getData() + const root = document.getElementById("root") + // Extract Labels + const labels = uniq(data.map( + ({ category }) => category), + ({ id }) => id + ).sort((a, b) => a.id - b.id) + createGraphs(root, data, labels) + generateTable(root, data, labels) +} + +/** + * Crete the Table + * @param {HTMLElement} root + * @param {Expense[]} data + * @param {str[]} labels + */ +function generateTable(root, data, labels) { + const summary = group_cat(data, labels) + + const table = document.createElement("table") + table.style.margin = "1rem auto" + root.append(table) + + table.createTHead() + table.createTFoot() + + const row = table.tHead.insertRow() + row.append(...["Category", "Amount"].map(element => { + const header = document.createElement("th") + header.append(element) + return header + })) + + summary.forEach(element => { + const row = table.insertRow() + + const link = document.createElement("a") + row.insertCell().append(link) + link.href = `/cat/${element.category.id}` + link.append(element.category.name) + + row.insertCell().append(element.amount.toFixed(2)) + }) + + const footer = table.tFoot.insertRow(); + [ + "Overall", + summary.reduce((partialSum, { amount }) => partialSum + amount, 0).toFixed(2) + ].forEach(element => { + footer.insertCell().append(element) + }) +} + +/** + * Create The Pie Charts + * @param {HTMLElement} root + * @param {Expense[]} data + * @param {str[]} labels + */ +function createGraphs(root, data, labels) { + const graphBar = document.createElement("div") + root.append(graphBar) + graphBar.style.display = "grid" + graphBar.style.gridAutoFlow = "column" + pieChart(graphBar, group_cat(data.filter(({ amount }) => amount < 0), labels), "Inflow") + pieChart(graphBar, group_cat(data.filter(({ amount }) => amount > 0), labels), "Outflow") +} + +/** + * Fetches the summary Data + * @returns {Promise<Expense[]>} + */ +async function getData() { + const res = await fetch("/summary.json") + const raw = await res.json() + + const data = raw.map(({ amount, category }) => ({ amount, category })) + return data +} + +/** + * Group the data by category + * @param {Expense[]} data + * @param {Category[]} labels + * @returns {Expense[]} + */ +function group_cat(data, labels = []) { + return data.reduce((accumulator, { category, amount }) => { + const index = accumulator.find(element => element.category.id == category.id) + if (index !== undefined) { + index.amount += amount + return accumulator + } else { + return [...accumulator, { category, amount }] + } + }, labels.map(category => ({ category, amount: 0 }))) +} + +/** + * Create a pie chart from the data + * @param {element} root + * @param {any[]} data + * @param {str} title + */ +function pieChart(root, data, title = "") { + const ctx = document.createElement('canvas') + + new Chart(ctx, { + type: "pie", + data: { + labels: data.map(({ category }) => category.name), + datasets: [{ + data: data.map(({ amount }) => amount) + }] + }, + options: { + plugins: { + legend: { + position: 'right', + }, + title: { + display: true, + text: title + } + } + } + }) + root.append(ctx) +} + +/** + * get the unique value in an array + * @param {any[]} arr + * @param {(any) => any} key + * @returns any[] + */ +function uniq(arr, key) { + var seen = new Set() + return arr.filter((item) => { + var k = key(item) + return seen.has(k) ? false : seen.add(k) + }) +} + + +window.onload = main
\ No newline at end of file diff --git a/src/expense/static/style.css b/src/expense/static/style.css new file mode 100644 index 0000000..4438cf6 --- /dev/null +++ b/src/expense/static/style.css @@ -0,0 +1,129 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + color-scheme: light dark; + width: 100dvw; + margin: auto; +} + +nav { + background: rgb(117, 117, 117); + display: flex; + align-items: center; +} + +nav>h1 { + flex: auto; +} + +nav>* { + display: inline; + padding: 0.5rem; + margin: 0; +} + +.content { + background: rgb(117, 117, 117); + display: none; + position: absolute; + right: 0; + min-width: 10rem; +} + +nav :is(span, a):hover { + font-style: italic; +} + +.dropdown { + position: relative; + display: inline; +} + +.dropdown>span { + display: block; + height: 100%; + padding: 0 0.5rem; +} + +.dropdown:hover .content { + display: block; +} + +.content>* { + display: block; + padding: 0.5rem; + width: 100%; + overflow: auto; +} + +nav *:any-link { + color: inherit; + text-decoration: inherit; +} + +form { + display: grid; + grid-gap: 2rem; + margin: auto; + width: max-content; +} + +form>label { + grid-column: 1; +} + +form>* { + grid-column: 2; + width: auto; +} + +form>button, +form>input[type="submit"] { + width: max-content; +} + +table, +th, +td { + border: 1px solid; + border-collapse: collapse; + padding: 1rem; +} + +thead th, +tfoot th, +tfoot td { + background: rgb(117, 117, 117); + border: 3px solid; +} + +th, +td { + padding: 1.5rem; + border: 1px solid; +} + +h1 { + font-size: xxx-large; +} + +h2 { + font-size: xx-large; +} + +main { + margin: 0 auto; + width: fit-content +} + +.button-group>* { + margin: 0 1rem; +} + +textarea { + min-height: 10rem; +} diff --git a/src/expense/templates/auth/delete.html b/src/expense/templates/auth/delete.html new file mode 100644 index 0000000..6a2822e --- /dev/null +++ b/src/expense/templates/auth/delete.html @@ -0,0 +1,13 @@ +{% from 'macros.html' import label_field %} +{% extends 'base.html' %} + +{% block header %} +<h2>{% block title %}Delete the Account. This is non reversable{% endblock %}</h2> +{% endblock %} + +{% block content %} +<form method="post"> + {{ form.hidden_tag() }} + {{ form.submit }} +</form> +{% endblock %}
\ No newline at end of file diff --git a/src/expense/templates/auth/login.html b/src/expense/templates/auth/login.html new file mode 100644 index 0000000..f223c3c --- /dev/null +++ b/src/expense/templates/auth/login.html @@ -0,0 +1,15 @@ +{% from 'macros.html' import label_field %} +{% extends 'base.html' %} + +{% block header %} +<h2>{% block title %}Log In{% endblock %}</h2> +{% endblock %} + +{% block content %} +<form method="post"> + {{ form.hidden_tag() }} + {{ label_field(form.username) }} + {{ label_field(form.password) }} + {{ form.submit }} +</form> +{% endblock %}
\ No newline at end of file diff --git a/src/expense/templates/auth/register.html b/src/expense/templates/auth/register.html new file mode 100644 index 0000000..c08bc55 --- /dev/null +++ b/src/expense/templates/auth/register.html @@ -0,0 +1,16 @@ +{% from 'macros.html' import label_field %} +{% extends 'base.html' %} + +{% block header %} +<h1>{% block title %}Register{% endblock %}</h1> +{% endblock %} + +{% block content %} +<form method="post"> + {{ form.hidden_tag() }} + {{ label_field(form.username) }} + {{ label_field(form.password) }} + {{ label_field(form.confirm) }} + {{ form.submit }} +</form> +{% endblock %}
\ No newline at end of file diff --git a/src/expense/templates/base.html b/src/expense/templates/base.html new file mode 100644 index 0000000..d9c6759 --- /dev/null +++ b/src/expense/templates/base.html @@ -0,0 +1,52 @@ +<!doctype html> +<html lang="en"> + +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{% block title %}{% endblock %} - Expense</title> + <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> + <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.3.0/chart.umd.js" + integrity="sha512-CMF3tQtjOoOJoOKlsS7/2loJlkyctwzSoDK/S40iAB+MqWSaf50uObGQSk5Ny/gfRhRCjNLvoxuCvdnERU4WGg==" + crossorigin="anonymous" referrerpolicy="no-referrer"></script> +</head> + +<body> + <header> + <nav> + <h1>Expense Tracker</h1> + {% if current_user.is_authenticated %} + <div class="dropdown"> + <span>Manage</span> + <div class="content"> + <a href="{{ url_for('index') }}">Summary</a> + <a href="{{ url_for('tracker.details') }}">Details</a> + <a href="{{ url_for('tracker.create') }}">Insert Entry</a> + <a href="{{ url_for('tracker.category') }}">Manage Category</a> + </div> + </div> + <div class="dropdown"> + <span>{{ current_user.name }}</span> + <div class="content"> + <a href="{{ url_for('auth.delete') }}">Delete Account</a> + <a href="{{ url_for('auth.logout') }}">Log Out</a> + </div> + </div> + {% else %} + <a href="{{ url_for('auth.register') }}">Register</a> + <a href="{{ url_for('auth.login') }}">Log In</a> + {% endif %} + </nav> + </header> + <main> + <header> + {% block header %}{% endblock %} + </header> + {% for message in get_flashed_messages() %} + <div class="flash">{{ message }}</div> + {% endfor %} + {% block content %}{% endblock %} + </main> +</body> + +</html>
\ No newline at end of file diff --git a/src/expense/templates/macros.html b/src/expense/templates/macros.html new file mode 100644 index 0000000..2d83155 --- /dev/null +++ b/src/expense/templates/macros.html @@ -0,0 +1,36 @@ +{% macro label_field(label) -%} +{% for error in label.errors %} +<span>{{ error }}</span> +{% endfor %} +{{ label.label }} +{{ label() }} +{%- endmacro %} + +{% macro expense_table(data, total) -%} +<table> + <thead> + <tr> + <th></th> + <th>Date</th> + <th>Category</th> + <th>Description</th> + <th>Amount</th> + </tr> + </thead> + <tbody> + {% for row in data %} + <tr> + <td><a href="{{ url_for("tracker.trans", id=row.id ) }}">Edit</a></td> + <td><time datetime="{{ row.date.strftime("%Y-%m-%d") }}">{{ row.date.strftime("%d/%m/%Y") }}<time></td> + <td><a href="{{ url_for("tracker.cat", id=row.category.id ) }}">{{ row.category.name }}</a></td> + <td>{{ row.description }}</td> + <td>{{ "%.2f"|format(row.amount) }}</td> + </tr> + {% endfor %} + <tfoot> + <td colspan="4">Overall</td> + <td>{{ "%.2f"|format(total) }}</td> + </tfoot> + </tbody> +</table> +{%- endmacro %}
\ No newline at end of file diff --git a/src/expense/templates/tracker/cat.html b/src/expense/templates/tracker/cat.html new file mode 100644 index 0000000..ee35193 --- /dev/null +++ b/src/expense/templates/tracker/cat.html @@ -0,0 +1,8 @@ +{% from 'macros.html' import expense_table %} +{% extends 'base.html' %} + + +{% block content %} +<h2>{{ category.name }}</h2> +{{ expense_table(table, total[0]) }} +{% endblock %}
\ No newline at end of file diff --git a/src/expense/templates/tracker/category.html b/src/expense/templates/tracker/category.html new file mode 100644 index 0000000..50c4a11 --- /dev/null +++ b/src/expense/templates/tracker/category.html @@ -0,0 +1,16 @@ +{% from 'macros.html' import label_field %} +{% extends 'base.html' %} + +{% block content %} +<h2>Manage Categories</h2> +<form method="post"> + <div> + {{ form.hidden_tag() }} + {% for cat in current_user.categories %} + <p>{{ cat.name }}</p> + {% endfor %} + </div> + {{ label_field(form.category) }} + {{ form.submit }} +</form> +{% endblock %} diff --git a/src/expense/templates/tracker/create.html b/src/expense/templates/tracker/create.html new file mode 100644 index 0000000..e010455 --- /dev/null +++ b/src/expense/templates/tracker/create.html @@ -0,0 +1,17 @@ +{% from 'macros.html' import label_field %} +{% extends 'base.html' %} + +{% block header %} +<h2>Create New Entry</h2> +{% endblock %} + +{% block content %} +<form method="post"> + {{ form.hidden_tag() }} + {{ label_field(form.date) }} + {{ label_field(form.category) }} + {{ label_field(form.description) }} + {{ label_field(form.amount) }} + {{ form.submit }} +</form> +{% endblock %} diff --git a/src/expense/templates/tracker/details.html b/src/expense/templates/tracker/details.html new file mode 100644 index 0000000..fa7eb1e --- /dev/null +++ b/src/expense/templates/tracker/details.html @@ -0,0 +1,10 @@ +{% from 'macros.html' import expense_table %} +{% extends 'base.html' %} + +{% block header %} + <h2>Details</h2> +{% endblock%} + +{% block content %} + {{ expense_table(current_user.expenses, total[0]) }} +{% endblock %}
\ No newline at end of file diff --git a/src/expense/templates/tracker/index.html b/src/expense/templates/tracker/index.html new file mode 100644 index 0000000..ce7bc92 --- /dev/null +++ b/src/expense/templates/tracker/index.html @@ -0,0 +1,11 @@ +{% from 'macros.html' import expense_table %} +{% extends 'base.html' %} + +{% block header %} +<h2>Summary</h2> +{% endblock %} + +{% block content %} +<script src="{{ url_for('static', filename='script.js') }}" type="module"></script> +<div id="root" ></div> +{% endblock %}
\ No newline at end of file diff --git a/src/expense/templates/tracker/trans.html b/src/expense/templates/tracker/trans.html new file mode 100644 index 0000000..449aa99 --- /dev/null +++ b/src/expense/templates/tracker/trans.html @@ -0,0 +1,17 @@ +{% from 'macros.html' import label_field %} +{% extends 'base.html' %} + +{% block content %} +<form method="post"> + <h2>Edit Entry</h2> + {{ form.hidden_tag() }} + {{ label_field(form.date) }} + {{ label_field(form.category) }} + {{ label_field(form.description) }} + {{ label_field(form.amount) }} + <div class="button-group"> + {{ form.submit }} + {{ form.delete }} + </div> +</form> +{% endblock %}
\ No newline at end of file diff --git a/src/expense/tracker/__init__.py b/src/expense/tracker/__init__.py new file mode 100644 index 0000000..d19e89c --- /dev/null +++ b/src/expense/tracker/__init__.py @@ -0,0 +1,5 @@ +""" +The Expense Tracker +""" + +from .blueprint import tracker diff --git a/src/expense/tracker/blueprint.py b/src/expense/tracker/blueprint.py new file mode 100644 index 0000000..960301e --- /dev/null +++ b/src/expense/tracker/blueprint.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +tracker = Blueprint("tracker", __name__) + +from . import create, index, trans, category, cat, details diff --git a/src/expense/tracker/cat.py b/src/expense/tracker/cat.py new file mode 100644 index 0000000..50aa02d --- /dev/null +++ b/src/expense/tracker/cat.py @@ -0,0 +1,29 @@ +from flask import ( + render_template, +) +from flask_login import current_user, login_required +from sqlalchemy import func +from .blueprint import tracker +from ..model import Category, Expense +from .. import db + + +@tracker.route("/cat/<int:id>") +@login_required +def cat(id: int) -> str: + """ + The per category view of the the user expense. + """ + category = db.one_or_404(db.select(Category).where(Category.id == id)) + + total = db.session.scalars( + db.select(func.coalesce(func.sum(Expense.amount), 0)).where( + Expense.user == current_user, Expense.category == category + ) + ).all() + table = db.session.scalars( + db.select(Expense).where( + Expense.user == current_user, Expense.category == category + ) + ).all() + return render_template("tracker/cat.html", table=table, total=total, category=category) diff --git a/src/expense/tracker/category.py b/src/expense/tracker/category.py new file mode 100644 index 0000000..a36f737 --- /dev/null +++ b/src/expense/tracker/category.py @@ -0,0 +1,43 @@ +from typing import Union +from flask import ( + redirect, + render_template, + url_for, +) +from flask_login import login_required, current_user +from werkzeug import Response + +from .. import db + +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField +from wtforms.validators import InputRequired + +from ..model import Category +from .blueprint import tracker + + +class CategoryForm(FlaskForm): + """Add Category""" + category = StringField("Add Category", validators=[InputRequired()]) # type: ignore + submit = SubmitField() + + +@tracker.route("/category", methods=["GET", "POST"]) +@login_required +def category() -> Union[str, Response]: + """ + To manage the list of user defined capabilities + """ + form = CategoryForm() + if form.validate_on_submit(): + category = form.category.data + cat = db.session.scalars( + db.select(Category).where(Category.name == category) + ).one_or_none() + if cat is None: + cat = Category(name=category) + current_user.categories.append(cat) # type: ignore + db.session.commit() + return redirect(url_for("index")) + return render_template("tracker/category.html", form=form) diff --git a/src/expense/tracker/create.py b/src/expense/tracker/create.py new file mode 100644 index 0000000..c6f8308 --- /dev/null +++ b/src/expense/tracker/create.py @@ -0,0 +1,61 @@ +from datetime import date +from flask import ( + redirect, + render_template, + url_for, +) +from flask_login import login_required, current_user + +from .. import db + +from flask_wtf import FlaskForm +from wtforms import ( + DateField, + TextAreaField, + DecimalField, + SubmitField, + SelectField, +) +from wtforms.widgets import NumberInput +from wtforms.validators import InputRequired + +from ..model import Category, Expense +from .blueprint import tracker + + +class CreateForm(FlaskForm): + """ + The Form to create a entry + """ + description = TextAreaField() + date = DateField( + validators=[InputRequired()], format="%Y-%m-%d", default=date.today + ) + amount = DecimalField(places=2, validators=[InputRequired()], widget=NumberInput(), default=0) # type: ignore + category = SelectField(coerce=int) # type: ignore + submit = SubmitField() + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.category.choices = [(cat.id, cat.name) for cat in current_user.categories] # type: ignore + + +@tracker.route("/create", methods=["GET", "POST"]) +@login_required +def create(): + """The view to handle new entry""" + form = CreateForm() + if form.validate_on_submit(): + cat = db.session.scalars( + db.select(Category).where(Category.id == form.category.data) + ).one() + expense = Expense( + description=form.description.data, + date=form.date.data, + amount=form.amount.data, + category=cat, + ) # type: ignore + current_user.expenses.append(expense) # type: ignore + db.session.commit() + return redirect(url_for("index")) + return render_template("tracker/create.html", form=form) diff --git a/src/expense/tracker/details.py b/src/expense/tracker/details.py new file mode 100644 index 0000000..3849a81 --- /dev/null +++ b/src/expense/tracker/details.py @@ -0,0 +1,22 @@ +from flask import ( + render_template, +) +from flask_login import current_user, login_required +from sqlalchemy import func +from .blueprint import tracker +from ..model import Expense +from .. import db + + +@tracker.route("/details") +@login_required +def details() -> str: + """ + The detail table of all the expenses + """ + total = db.session.scalars( + db.select(func.coalesce(func.sum(Expense.amount), 0)).where( + Expense.user == current_user + ) + ).all() + return render_template("tracker/details.html", total=total) diff --git a/src/expense/tracker/index.py b/src/expense/tracker/index.py new file mode 100644 index 0000000..946ef67 --- /dev/null +++ b/src/expense/tracker/index.py @@ -0,0 +1,24 @@ +from flask import render_template, jsonify +from werkzeug import Response +from werkzeug.local import LocalProxy +from flask_login import current_user, login_required +from .blueprint import tracker + + +@tracker.route("/") +@login_required +def index() -> str: + """ + The dashboard + """ + return render_template("tracker/index.html") + + +@tracker.route("/summary.json") +@login_required +def summary() -> Response: + """ + The Summary Api + Returns the list of all of the user expenses in json + """ + return jsonify(current_user.expenses) # type: ignore diff --git a/src/expense/tracker/templates/cat.html b/src/expense/tracker/templates/cat.html new file mode 100644 index 0000000..ee35193 --- /dev/null +++ b/src/expense/tracker/templates/cat.html @@ -0,0 +1,8 @@ +{% from 'macros.html' import expense_table %} +{% extends 'base.html' %} + + +{% block content %} +<h2>{{ category.name }}</h2> +{{ expense_table(table, total[0]) }} +{% endblock %}
\ No newline at end of file diff --git a/src/expense/tracker/templates/category.html b/src/expense/tracker/templates/category.html new file mode 100644 index 0000000..50c4a11 --- /dev/null +++ b/src/expense/tracker/templates/category.html @@ -0,0 +1,16 @@ +{% from 'macros.html' import label_field %} +{% extends 'base.html' %} + +{% block content %} +<h2>Manage Categories</h2> +<form method="post"> + <div> + {{ form.hidden_tag() }} + {% for cat in current_user.categories %} + <p>{{ cat.name }}</p> + {% endfor %} + </div> + {{ label_field(form.category) }} + {{ form.submit }} +</form> +{% endblock %} diff --git a/src/expense/tracker/templates/create.html b/src/expense/tracker/templates/create.html new file mode 100644 index 0000000..e010455 --- /dev/null +++ b/src/expense/tracker/templates/create.html @@ -0,0 +1,17 @@ +{% from 'macros.html' import label_field %} +{% extends 'base.html' %} + +{% block header %} +<h2>Create New Entry</h2> +{% endblock %} + +{% block content %} +<form method="post"> + {{ form.hidden_tag() }} + {{ label_field(form.date) }} + {{ label_field(form.category) }} + {{ label_field(form.description) }} + {{ label_field(form.amount) }} + {{ form.submit }} +</form> +{% endblock %} diff --git a/src/expense/tracker/templates/details.html b/src/expense/tracker/templates/details.html new file mode 100644 index 0000000..fa7eb1e --- /dev/null +++ b/src/expense/tracker/templates/details.html @@ -0,0 +1,10 @@ +{% from 'macros.html' import expense_table %} +{% extends 'base.html' %} + +{% block header %} + <h2>Details</h2> +{% endblock%} + +{% block content %} + {{ expense_table(current_user.expenses, total[0]) }} +{% endblock %}
\ No newline at end of file diff --git a/src/expense/tracker/templates/index.html b/src/expense/tracker/templates/index.html new file mode 100644 index 0000000..ce7bc92 --- /dev/null +++ b/src/expense/tracker/templates/index.html @@ -0,0 +1,11 @@ +{% from 'macros.html' import expense_table %} +{% extends 'base.html' %} + +{% block header %} +<h2>Summary</h2> +{% endblock %} + +{% block content %} +<script src="{{ url_for('static', filename='script.js') }}" type="module"></script> +<div id="root" ></div> +{% endblock %}
\ No newline at end of file diff --git a/src/expense/tracker/templates/trans.html b/src/expense/tracker/templates/trans.html new file mode 100644 index 0000000..449aa99 --- /dev/null +++ b/src/expense/tracker/templates/trans.html @@ -0,0 +1,17 @@ +{% from 'macros.html' import label_field %} +{% extends 'base.html' %} + +{% block content %} +<form method="post"> + <h2>Edit Entry</h2> + {{ form.hidden_tag() }} + {{ label_field(form.date) }} + {{ label_field(form.category) }} + {{ label_field(form.description) }} + {{ label_field(form.amount) }} + <div class="button-group"> + {{ form.submit }} + {{ form.delete }} + </div> +</form> +{% endblock %}
\ No newline at end of file diff --git a/src/expense/tracker/trans.py b/src/expense/tracker/trans.py new file mode 100644 index 0000000..5caecb8 --- /dev/null +++ b/src/expense/tracker/trans.py @@ -0,0 +1,56 @@ +from typing import Union +from flask import ( + redirect, + render_template, + url_for, +) +from flask_login import login_required, current_user +from werkzeug import Response + +from .. import db +from ..model import Category, Expense +from .blueprint import tracker +from .create import CreateForm + +from wtforms import SubmitField + + +class TransForm(CreateForm): + """ + Editing the transaction. + Adds the delete button + """ + delete = SubmitField() + + +@tracker.route("/trans/<int:id>", methods=["GET", "POST"]) +@login_required +def trans(id: int) -> Union[str, Response]: + """ + Edits the transaction. It also has the capability of deleting it. + """ + expense: Expense = db.one_or_404(db.select(Expense).where(Expense.id == id, Expense.user == current_user)) # type: ignore + form: TransForm = TransForm( + description=expense.description, + date=expense.date, + amount=expense.amount, + category_id=expense.category.id, + ) + form.category.choices = [(cat.id, cat.name) for cat in current_user.categories] # type: ignore + if form.validate_on_submit(): + if form.delete.data: + db.session.delete(expense) + elif form.submit.data: + cat = db.session.scalars( + db.select(Category).where(Category.id == form.category.data) + ).one() + expense.description = form.description.data + expense.date = form.date.data # type: ignore + expense.amount = form.amount.data # type: ignore + expense.category = cat + + current_user.expenses.append(expense) # type: ignore + + db.session.commit() + return redirect(url_for("index")) + return render_template("tracker/trans.html", form=form) |