aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
author2023-07-30 15:24:43 +0530
committer2023-07-30 15:24:43 +0530
commit3c9d4a79f8f8fd06c0c77a34a78ae0daf0c555b2 (patch)
treee537c911e166c2d85dfab65d1e57cc7e1be8ca3e
downloadexpensetracker-3c9d4a79f8f8fd06c0c77a34a78ae0daf0c555b2.tar
expensetracker-3c9d4a79f8f8fd06c0c77a34a78ae0daf0c555b2.tar.gz
expensetracker-3c9d4a79f8f8fd06c0c77a34a78ae0daf0c555b2.tar.bz2
expensetracker-3c9d4a79f8f8fd06c0c77a34a78ae0daf0c555b2.tar.lz
expensetracker-3c9d4a79f8f8fd06c0c77a34a78ae0daf0c555b2.tar.xz
expensetracker-3c9d4a79f8f8fd06c0c77a34a78ae0daf0c555b2.tar.zst
expensetracker-3c9d4a79f8f8fd06c0c77a34a78ae0daf0c555b2.zip
Initial Commit
Signed-off-by: Marc Pervaz Boocha <mboocha@sudomsg.xyz>
-rw-r--r--.gitignore5
-rw-r--r--README.md48
-rw-r--r--pyproject.toml17
-rw-r--r--src/expense/__init__.py5
-rw-r--r--src/expense/__main__.py13
-rw-r--r--src/expense/app.py54
-rw-r--r--src/expense/auth/__init__.py5
-rw-r--r--src/expense/auth/blueprint.py20
-rw-r--r--src/expense/auth/delete.py31
-rw-r--r--src/expense/auth/login.py63
-rw-r--r--src/expense/auth/logout.py14
-rw-r--r--src/expense/auth/password.py45
-rw-r--r--src/expense/auth/register.py80
-rw-r--r--src/expense/model.py63
-rw-r--r--src/expense/static/script.js165
-rw-r--r--src/expense/static/style.css129
-rw-r--r--src/expense/templates/auth/delete.html13
-rw-r--r--src/expense/templates/auth/login.html15
-rw-r--r--src/expense/templates/auth/register.html16
-rw-r--r--src/expense/templates/base.html52
-rw-r--r--src/expense/templates/macros.html36
-rw-r--r--src/expense/templates/tracker/cat.html8
-rw-r--r--src/expense/templates/tracker/category.html16
-rw-r--r--src/expense/templates/tracker/create.html17
-rw-r--r--src/expense/templates/tracker/details.html10
-rw-r--r--src/expense/templates/tracker/index.html11
-rw-r--r--src/expense/templates/tracker/trans.html17
-rw-r--r--src/expense/tracker/__init__.py5
-rw-r--r--src/expense/tracker/blueprint.py5
-rw-r--r--src/expense/tracker/cat.py29
-rw-r--r--src/expense/tracker/category.py43
-rw-r--r--src/expense/tracker/create.py61
-rw-r--r--src/expense/tracker/details.py22
-rw-r--r--src/expense/tracker/index.py24
-rw-r--r--src/expense/tracker/templates/cat.html8
-rw-r--r--src/expense/tracker/templates/category.html16
-rw-r--r--src/expense/tracker/templates/create.html17
-rw-r--r--src/expense/tracker/templates/details.html10
-rw-r--r--src/expense/tracker/templates/index.html11
-rw-r--r--src/expense/tracker/templates/trans.html17
-rw-r--r--src/expense/tracker/trans.py56
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)