├── .gitignore ├── app ├── static │ ├── fonts │ │ └── JosefinSans-Medium.ttf │ └── css │ │ ├── settings.css │ │ ├── admin.css │ │ ├── home.css │ │ ├── statements.css │ │ └── main.css ├── templates │ ├── admin │ │ ├── base.html │ │ ├── login.html │ │ ├── index.html │ │ ├── user.html │ │ └── users.html │ ├── auth │ │ └── index.html │ ├── home │ │ ├── new_statement.html │ │ ├── index.html │ │ ├── statement.html │ │ └── statements.html │ ├── base.html │ └── settings │ │ └── index.html ├── commands.py ├── __init__.py ├── db.py ├── views │ ├── auth.py │ ├── settings.py │ ├── home.py │ └── admin.py ├── app_functions.py ├── functions.py └── forms.py ├── main.py ├── requirements.txt ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | env/ 2 | migrations/ 3 | instance/ 4 | __pycache__/ -------------------------------------------------------------------------------- /app/static/fonts/JosefinSans-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hakiKhuva/expense-tracker/HEAD/app/static/fonts/JosefinSans-Medium.ttf -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | 3 | app = create_app() 4 | 5 | if __name__ == "__main__": 6 | app.run(debug=True, host="0.0.0.0") -------------------------------------------------------------------------------- /app/static/css/settings.css: -------------------------------------------------------------------------------- 1 | form .br { 2 | border-bottom: 2px solid var(--fg1) !important; 3 | margin: 15px 0px; 4 | } 5 | 6 | form input[type="submit"]{ 7 | width: max-content; 8 | padding: 10px 30px !important; 9 | border-radius: 30px !important; 10 | } -------------------------------------------------------------------------------- /app/templates/admin/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block head %} 4 | 5 | {% endblock %} 6 | 7 | {% block body_before %} 8 | {% if not is_loggedin is false %} 9 | 17 | {% endif %} 18 | {% endblock %} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.8.1 2 | click==8.1.3 3 | colorama==0.4.6 4 | contourpy==1.0.6 5 | cycler==0.11.0 6 | dnspython==2.2.1 7 | email-validator==1.3.0 8 | Flask==2.2.5 9 | Flask-Migrate==4.0.0 10 | Flask-SQLAlchemy==3.0.2 11 | Flask-WTF==1.0.1 12 | fonttools==4.38.0 13 | greenlet==2.0.1 14 | idna==3.4 15 | itsdangerous==2.1.2 16 | Jinja2==3.1.2 17 | kiwisolver==1.4.4 18 | Mako==1.2.4 19 | MarkupSafe==2.1.1 20 | matplotlib==3.6.2 21 | mysql-connector-python==8.0.31 22 | numpy==1.23.5 23 | packaging==22.0 24 | Pillow==9.3.0 25 | protobuf==3.19.6 26 | pyparsing==3.0.9 27 | python-dateutil==2.8.2 28 | six==1.16.0 29 | SQLAlchemy==1.4.44 30 | ua-parser==0.16.1 31 | user-agents==2.2.0 32 | waitress==2.1.2 33 | Werkzeug==2.2.3 34 | WTForms==3.0.1 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Expense Tracker 2 | 3 | Expense tracker in Python Flask supports following actions 4 | 5 | - Login/Signup 6 | - Add statement 7 | - Delete statement 8 | - Calculate final amount 9 | - Show all statements 10 | - Admin functionality 11 | - Download all statements 12 | 13 | ## Run the project 14 | 15 | > You must have python3 installed on your system before running 16 | 17 | > used python 3.10.2 for this project 18 | 19 | ### Create virtual environment and activate it 20 | 21 | ```bash 22 | # bash 23 | $ python3 -m venv env 24 | 25 | # windows 26 | > python -m venv env 27 | > env\Scripts\activate 28 | ``` 29 | 30 | ### Install requirements 31 | 32 | ```bash 33 | $ pip install -r requirements.txt 34 | ``` 35 | 36 | ### Set secret key and database URI 37 | 38 | ```bash 39 | # bash 40 | (env) $ export DB_URI="database uri" 41 | (env) $ export SECRET_KEY="secret key" 42 | 43 | # windows 44 | (env) > SET DB_URI=database_uri 45 | (env) > SET SECRET_KEY=secret_key 46 | ``` 47 | 48 | ### final run 49 | ```bash 50 | $ flask run 51 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Harkishan Khuva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/templates/admin/login.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block body %} 4 |
5 |

Admin login

6 |
7 | {{ form.csrf_token() }} 8 |
9 | {{ form.email.label }} 10 | {{ form.email() }} 11 | 12 | {% if form.email.errors %} 13 |
14 | {% for error in form.email.errors %} 15 |

{{error}}

16 | {% endfor %} 17 |
18 | {% endif %} 19 |
20 |
21 | {{ form.password.label }} 22 | {{ form.password() }} 23 | 24 | {% if form.password.errors %} 25 |
26 | {% for error in form.password.errors %} 27 |

{{error}}

28 | {% endfor %} 29 |
30 | {% endif %} 31 |
32 | 33 | {{ form.submit }} 34 |
35 |
36 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block body %} 4 |
5 |

Dashboard

6 | 7 | 8 | 9 | 10 | 11 | {% for key, value in table_data.items() %} 12 | 13 | 16 | 19 | 20 | {% endfor %} 21 |
Application Statistics
14 | {{key}} 15 | 17 | {{value}} 18 |
22 | 23 |
24 |
25 | Browser usage pie chart 26 |
27 | 28 |
29 | Browser usage pie chart 30 |
31 | 32 |
33 | Browser usage pie chart 34 |
35 | 36 |
37 |
38 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/auth/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 |

Login to account

6 |
7 | {{ form.csrf_token }} 8 |
9 | {{ form.email.label }} 10 | {{ form.email }} 11 | {% if form.email.errors %} 12 |
13 | {% for error in form.email.errors %} 14 |

{{error}}

15 | {% endfor %} 16 |
17 | {% endif %} 18 |
19 |
20 | {{ form.password.label }} 21 | {{ form.password }} 22 | {% if form.password.errors %} 23 |
24 | {% for error in form.password.errors %} 25 |

{{error}}

26 | {% endfor %} 27 |
28 | {% endif %} 29 |
30 |
31 | *By clicking below/creating new account you agreed to our privacy policy and terms of use to this website 32 |
33 | {{ form.submit }} 34 |
35 |
36 | {% endblock %} -------------------------------------------------------------------------------- /app/commands.py: -------------------------------------------------------------------------------- 1 | import click 2 | from flask.cli import with_appcontext 3 | from .db import Admin, db 4 | from werkzeug.security import generate_password_hash 5 | from .functions import generate_id 6 | import re 7 | 8 | regex = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b' 9 | regex = re.compile(regex) 10 | 11 | @click.command(name="createadminuser") 12 | @with_appcontext 13 | def create_admin_user(): 14 | username = "" 15 | while not username.strip(): 16 | username = input("Enter username : ") 17 | 18 | email = "" 19 | while not email.strip(): 20 | email = input("Enter your email : ") 21 | if not regex.match(email): 22 | print("Invalid email address") 23 | email = "" 24 | 25 | password = "" 26 | while not password.strip() or len(password) < 8: 27 | password = input("Enter your password : ") 28 | 29 | account = Admin.query.filter(Admin.email == email).first() 30 | 31 | if account: 32 | print("Email already used with another account!") 33 | return 34 | 35 | hashed_password = generate_password_hash(password, "SHA256") 36 | session_id = generate_id(username, email, hashed_password) 37 | 38 | admin = Admin( 39 | username = username, 40 | email = email, 41 | password = hashed_password, 42 | session_id = session_id 43 | ) 44 | 45 | db.session.add(admin) 46 | db.session.commit() 47 | 48 | print("Admin created successfully") -------------------------------------------------------------------------------- /app/templates/admin/user.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block body %} 4 |
5 |

User's details

6 |
7 | {{ form.csrf_token() }} 8 |
9 | {{ form.name.label }} 10 | {{ form.name() }} 11 | {% if form.name.errors %} 12 |
13 | {% for error in form.name.errors %} 14 |

{{ error }}

15 | {% endfor %} 16 |
17 | {% endif %} 18 |
19 |
20 | {{ form.email.label }} 21 | {{ form.email() }} 22 | {% if form.email.errors %} 23 |
24 | {% for error in form.email.errors %} 25 |

{{ error }}

26 | {% endfor %} 27 |
28 | {% endif %} 29 |
30 |
31 | {{ form.password.label }} 32 | {{ form.password() }} 33 | {% if form.password.errors %} 34 |
35 | {% for error in form.password.errors %} 36 |

{{ error }}

37 | {% endfor %} 38 |
39 | {% endif %} 40 |
41 | 42 | {{ form.update_account() }} 43 | {{ form.delete_account() }} 44 |
45 |
46 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/home/new_statement.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 | << Back to Home 6 |

Add Statement

7 |
8 | {{ form.csrf_token() }} 9 |
10 | {{ form.amount.label }} 11 | {{ form.amount() }} 12 | {% if form.amount.errors %} 13 |
14 | {% for error in form.amount.errors %} 15 |

{{error}}

16 | {% endfor %} 17 |
18 | {% endif %} 19 |
20 |
21 | {{ form.description.label }} 22 | {{ form.description() }} 23 | {% if form.description.errors %} 24 |
25 | {% for error in form.description.errors %} 26 |

{{error}}

27 | {% endfor %} 28 |
29 | {% endif %} 30 |
31 |
32 | {{ form.datetime_data.label }} 33 | {{ form.datetime_data() }} 34 | {% if form.datetime_data.errors %} 35 |
36 | {% for error in form.datetime_data.errors %} 37 |

{{error}}

38 | {% endfor %} 39 |
40 | {% endif %} 41 |
42 | 43 | {{ form.expense }} 44 | {{ form.income }} 45 |
46 |
47 | {% endblock %} -------------------------------------------------------------------------------- /app/static/css/admin.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg : #fff; 3 | --shade : #444; 4 | --fg : rgb(0, 65, 25); 5 | --fg1 : #000; 6 | --max-w : 1250px; 7 | } 8 | 9 | .head { 10 | font-size: 22.5px; 11 | margin: 20px 0px; 12 | } 13 | 14 | .container { 15 | max-width: var(--max-w); 16 | margin: auto; 17 | } 18 | 19 | .flexbox { 20 | display: flex; 21 | flex-direction: row; 22 | align-items: stretch; 23 | justify-content: space-between; 24 | flex-wrap: wrap; 25 | } 26 | 27 | .flex-column { 28 | flex-direction: column; 29 | align-items: center; 30 | justify-content: center; 31 | } 32 | 33 | .container .content { 34 | box-shadow: 0px 0px 1px 0px var(--fg); 35 | border-radius: 5px; 36 | padding: 20px; 37 | margin: 20px; 38 | } 39 | 40 | .container .image { 41 | border-radius: 5px; 42 | } 43 | 44 | table { 45 | margin: 50px; 46 | width: 100vw; 47 | max-width: calc(100% - 110px); 48 | } 49 | 50 | table, table tr, table td { 51 | /* border: 1px solid var(--fg); */ 52 | box-shadow: 0px 0px 1px 0px var(--fg1); 53 | border-collapse: collapse; 54 | } 55 | 56 | table td, table th { 57 | padding: 15px; 58 | font-size: 18px; 59 | } 60 | 61 | nav { 62 | margin-bottom: 50px; 63 | display: flex; 64 | flex-direction: row; 65 | flex-wrap: wrap; 66 | max-width: calc(100%); 67 | align-items: center; 68 | justify-content: center; 69 | margin-top: 0px; 70 | width: 100%; 71 | top: 0; 72 | left: 0; 73 | right: 0; 74 | position: sticky; 75 | } 76 | 77 | nav,nav * { 78 | background-color: #ddd; 79 | } 80 | 81 | nav > a, nav input[type="submit"] { 82 | display: block; 83 | padding: 25px 20px; 84 | transition: 200ms; 85 | border: 0px; 86 | border-bottom: 1px solid #000; 87 | } 88 | 89 | nav > a:hover, nav input[type="submit"]:hover { 90 | filter: invert(100%); 91 | transition: 400ms; 92 | } -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask, request 3 | from flask_migrate import Migrate 4 | from user_agents import parse 5 | import datetime 6 | 7 | from .commands import create_admin_user 8 | from .db import db, VisitorStats 9 | 10 | migrate = Migrate(render_as_batch=True) 11 | 12 | def create_app(): 13 | app = Flask(__name__) 14 | 15 | app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "NOTHING_IS_SECRET") 16 | app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DB_URI", "sqlite:///master.sqlite3") 17 | app.config["PERMANENT_SESSION_LIFETIME"] = datetime.timedelta(days=7) 18 | 19 | db.init_app(app) 20 | migrate.init_app(app, db) 21 | 22 | @app.after_request 23 | def after_request_(response): 24 | if request.endpoint != "static": 25 | response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 26 | return response 27 | 28 | from .views.auth import auth 29 | from .views.home import home 30 | from .views.settings import settings 31 | from .views.admin import admin 32 | 33 | blueprints = [auth, home, settings, admin ] 34 | 35 | # request.blueprint != admin.name and 36 | @app.before_request 37 | def app_before_data(): 38 | if request.endpoint != "static": 39 | user_agent = parse(request.user_agent.string) 40 | browser = user_agent.browser.family 41 | device = user_agent.get_device() 42 | operating_system = user_agent.get_os() 43 | bot = user_agent.is_bot 44 | 45 | stat = VisitorStats( 46 | browser = browser, 47 | device = device, 48 | operating_system = operating_system, 49 | is_bot = bot 50 | ) 51 | db.session.add(stat) 52 | db.session.commit() 53 | 54 | for bp in blueprints: app.register_blueprint(bp) 55 | 56 | app.cli.add_command(create_admin_user) 57 | 58 | return app -------------------------------------------------------------------------------- /app/templates/home/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block head %} 4 | 5 | 6 | {% endblock %} 7 | 8 | {% block body %} 9 |
10 |
Hello, {{ user.name }}
11 |
12 |
13 |
14 | 15 | {{ user.account_balance }} 16 |
17 |
18 |
19 |
20 | Add statement 21 | View statements 22 | Settings 23 |
24 |
25 |
26 |
27 |

Last statements

28 |
29 | {% if user.statements %} 30 | {% for statement in user.statements %} 31 | 32 |
33 |
{{statement.desc}}
34 |
{{statement.at}}
35 |
36 |
₹{{statement.amount}}
37 |
38 | {% endfor %} 39 | {% else %} 40 |

You've not added any statements yet.

41 | {% endif %} 42 |
43 |
44 |
45 | {% endblock %} -------------------------------------------------------------------------------- /app/static/css/home.css: -------------------------------------------------------------------------------- 1 | #expense-card { 2 | border-radius: 10px; 3 | } 4 | 5 | 6 | #expense-card > #name { 7 | font-size: 35px; 8 | margin: 20px auto; 9 | text-align: center; 10 | } 11 | 12 | #expense-card #balance { 13 | display: flex; 14 | flex-direction: row; 15 | font-size: 45px; 16 | justify-content: center; 17 | } 18 | 19 | #expense-card #balance > #currency { 20 | margin: 0px 5px; 21 | } 22 | 23 | #expense-card #balance > #num, .amount { 24 | margin: 0px 5px; 25 | font-family: var(--font-s); 26 | } 27 | 28 | #expense-card > #top { 29 | display: flex; 30 | flex-direction: row; 31 | flex-wrap: wrap; 32 | align-content: stretch; 33 | justify-content: space-between; 34 | } 35 | 36 | #expense-card > #top .part { 37 | width: calc( 50% - 70px ); 38 | } 39 | 40 | .part, 41 | .part * { 42 | background-color: #444; 43 | } 44 | 45 | .part { 46 | padding: 25px; 47 | border-radius: 10px; 48 | margin: 10px; 49 | } 50 | 51 | #expense-card #operations { 52 | display: flex; 53 | flex-direction: column; 54 | } 55 | 56 | #expense-card #operations > .btn-a { 57 | display: block; 58 | text-decoration: none; 59 | } 60 | 61 | #expense-card #operations > button[type="button"], 62 | #expense-card #operations > .btn-a { 63 | text-align: center; 64 | background-color: #333; 65 | color: var(--fg1); 66 | padding: 10px 0px; 67 | font-size: 16.5px; 68 | margin: 10px auto; 69 | border: 0px; 70 | box-shadow: 0px 0px 2px 0px var(--fg1); 71 | border-radius: 3px; 72 | cursor: pointer; 73 | width: 200px; 74 | max-width: 100%; 75 | } 76 | 77 | #statements > .statement, 78 | #statements > .statement * { 79 | background-color: #373737 !important; 80 | } 81 | 82 | @media (max-width:700px) { 83 | #expense-card > #top { 84 | flex-direction: column; 85 | } 86 | 87 | #expense-card > #top .part { 88 | width: calc( 100% - 70px ); 89 | } 90 | 91 | #expense-card { 92 | padding: 10px; 93 | margin: 10px auto; 94 | width: calc(100% - 30px); 95 | max-width: 850px; 96 | } 97 | } -------------------------------------------------------------------------------- /app/db.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | import datetime 3 | 4 | db = SQLAlchemy() 5 | 6 | class User(db.Model): 7 | __tablename__ = "user" 8 | id = db.Column(db.Integer, primary_key=True) 9 | name = db.Column(db.String(50), nullable=False) 10 | email = db.Column(db.String(100), nullable=False, unique=True) 11 | password = db.Column(db.Text, nullable=False) 12 | session_id = db.Column(db.Text, nullable=False) 13 | 14 | statements = db.relationship("Statements", backref=db.backref("user"), passive_deletes=True) 15 | 16 | def __repr__(self) -> str: 17 | return ""%(str(self.id)) 18 | 19 | 20 | class VisitorStats(db.Model): 21 | __tablename__ = "visitor_stats" 22 | id = db.Column(db.Integer, primary_key=True) 23 | browser = db.Column(db.String(100)) 24 | device = db.Column(db.String(100)) 25 | operating_system = db.Column(db.String(100)) 26 | is_bot = db.Column(db.Boolean()) 27 | date = db.Column(db.DateTime, default=datetime.datetime.utcnow) 28 | 29 | def __repr__(self) -> str: 30 | return ""%(str(self.id)) 31 | 32 | 33 | class Statements(db.Model): 34 | __tablename__ = "statements" 35 | id = db.Column(db.Integer, primary_key=True) 36 | description = db.Column(db.String(200), nullable=False) 37 | amount = db.Column(db.Numeric(10,2), nullable=False) 38 | operation_time = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) 39 | statement_id = db.Column(db.String(50), nullable=False, unique=True) 40 | 41 | user_id = db.Column(db.Integer, db.ForeignKey("user.id", ondelete="CASCADE")) 42 | 43 | def __repr__(self) -> str: 44 | return ""%(str(self.id)) 45 | 46 | 47 | class Admin(db.Model): 48 | __tablename__ = "admin" 49 | id = db.Column(db.Integer, primary_key=True) 50 | username = db.Column(db.String(50), nullable=False) 51 | email = db.Column(db.String(100), nullable=False, unique=True) 52 | password = db.Column(db.Text, nullable=False) 53 | session_id = db.Column(db.String(50), nullable=False, unique=True) 54 | 55 | def __repr__(self) -> str: 56 | return ""%(str(self.id)) -------------------------------------------------------------------------------- /app/templates/home/statement.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block head %} 4 | 5 | {% endblock %} 6 | 7 | {% block body %} 8 | 9 |
10 |
11 | << Back to Home 12 |

Statement details

13 |
14 | {{ form.csrf_token() }} 15 |
16 | {{ form.amount.label }} 17 | {{ form.amount() }} 18 | {% if form.amount.errors %} 19 |
20 | {% for error in form.amount.errors %} 21 |

{{error}}

22 | {% endfor %} 23 |
24 | {% endif %} 25 |
26 |
27 | {{ form.description.label }} 28 | {{ form.description() }} 29 | {% if form.description.errors %} 30 |
31 | {% for error in form.description.errors %} 32 |

{{error}}

33 | {% endfor %} 34 |
35 | {% endif %} 36 |
37 |
38 | {{ form.datetime_data.label }} 39 | {{ form.datetime_data() }} 40 | {% if form.datetime_data.errors %} 41 |
42 | {% for error in form.datetime_data.errors %} 43 |

{{error}}

44 | {% endfor %} 45 |
46 | {% endif %} 47 |
48 | 49 |
50 | {{ form.expense }} 51 | {{ form.income }} 52 | {{ form.delete_statement }} 53 |
54 |
55 |
56 |
57 | 58 | {% endblock %} -------------------------------------------------------------------------------- /app/views/auth.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, request, flash, redirect, url_for, session 2 | from werkzeug.security import generate_password_hash, check_password_hash 3 | 4 | from ..forms import AuthForm 5 | from ..functions import generate_id, generate_string, get_base64_encode 6 | from ..app_functions import get_login, user_login_required 7 | from ..db import User, db 8 | 9 | 10 | auth = Blueprint("Auth",__name__, url_prefix="/auth") 11 | 12 | @auth.route("/", methods=("GET","POST")) 13 | def auth_index(): 14 | if get_login() is True: 15 | return redirect(url_for("Home.home_index")) 16 | 17 | form = AuthForm(request.form) 18 | 19 | if form.validate_on_submit(): 20 | email = form.email.data 21 | password = form.password.data 22 | 23 | data = User.query.filter(User.email == email).first() 24 | 25 | if data: 26 | if check_password_hash(data.password, password) is True: 27 | session.permanent = True 28 | session["session-sign-id"] = get_base64_encode(data.session_id) 29 | return redirect(url_for("Home.home_index")) 30 | else: 31 | flash("Incorrect password for entered email account!", "red") 32 | else: 33 | hashed_password = generate_password_hash(password, "SHA256") 34 | name = email.split("@")[0][:18] 35 | user = User( 36 | name = name, 37 | email = email, 38 | password = hashed_password, 39 | session_id = generate_id(name, email, hashed_password) 40 | ) 41 | 42 | db.session.add(user) 43 | db.session.commit() 44 | 45 | flash("New account has been created, enter credentials to signin.", "green") 46 | 47 | return redirect(url_for(".auth_index")) 48 | 49 | return render_template( 50 | "auth/index.html", 51 | title="Login", 52 | form=form 53 | ) 54 | 55 | @auth.route("/logout", methods=["GET","POST"]) 56 | @user_login_required 57 | def logout(): 58 | if "session-sign-id" in session: 59 | session.pop("session-sign-id") 60 | session.permanent = False 61 | return redirect(url_for(".auth_index")) -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% if title %} 8 | {{ title }} 9 | {% else %} 10 | Expense-Tracker 11 | {% endif %} 12 | 13 | 14 | 15 | {% block head %} 16 | {% endblock %} 17 | 18 | 23 | 24 | 25 |
26 |
27 | Expense-Tracker 28 |
29 | {% with msgs = get_flashed_messages(with_categories=true) %} 30 | {% if msgs %} 31 |
32 | {% for cat, msg in msgs %} 33 |
34 | {{msg}} 35 | 36 |
37 | {% endfor %} 38 |
39 | {% endif %} 40 | {% endwith %} 41 |
42 | {% block body_before %} 43 | {% endblock %} 44 | {% block body %} 45 | {% endblock %} 46 |
47 | {% if footer is true %} 48 | 65 | {% endif %} 66 |
67 | 68 | -------------------------------------------------------------------------------- /app/app_functions.py: -------------------------------------------------------------------------------- 1 | from flask import session, redirect, url_for 2 | from .db import User, Statements, Admin 3 | from .functions import get_base64_decode 4 | from sqlalchemy import func 5 | from functools import wraps 6 | 7 | 8 | def get_login() -> bool: 9 | """ 10 | returns whether user is logged in or not 11 | """ 12 | sign_id = session.get("session-sign-id") 13 | if sign_id: 14 | sign_id = get_base64_decode(sign_id) 15 | if sign_id and sign_id != "": 16 | user = User.query.filter(User.session_id == sign_id).first() 17 | if user: 18 | return True 19 | session.pop("session-sign-id") 20 | return False 21 | 22 | 23 | def get_admin_login() -> bool: 24 | """ 25 | returns whether user is logged in or not 26 | """ 27 | sign_id = session.get("admin-sign-id") 28 | if sign_id: 29 | sign_id = get_base64_decode(sign_id) 30 | if sign_id and sign_id != "": 31 | user = Admin.query.filter(User.session_id == sign_id).first() 32 | if user: 33 | return True 34 | session.pop("admin-sign-id") 35 | return False 36 | 37 | 38 | def get_current_user(): 39 | """ 40 | returns current user if logged in, run `get_login` 41 | before running this function to check login status 42 | 43 | returns User if loggedin 44 | 45 | return None if not loggedin 46 | """ 47 | sign_id = session.get("session-sign-id") 48 | if sign_id: 49 | sign_id = get_base64_decode(sign_id) 50 | if sign_id and sign_id != "": 51 | user = User.query.filter(User.session_id == sign_id).first() 52 | return user 53 | return None 54 | 55 | # def get_current_user_account(user_id): 56 | def get_current_user_balance(): 57 | """ 58 | returns the user_account table user information 59 | """ 60 | user_id = get_current_user().id 61 | account_balance = Statements.query.with_entities(func.sum(Statements.amount)).filter(Statements.user_id == user_id).first()[0] 62 | if account_balance is None: 63 | return 0.00 64 | return account_balance 65 | 66 | 67 | 68 | def user_login_required(f): 69 | @wraps(f) 70 | def fun(*args, **kwargs): 71 | if get_login() is True: 72 | return f(*args, **kwargs) 73 | return redirect(url_for("Auth.auth_index")) 74 | return fun 75 | 76 | 77 | def admin_login_required(f): 78 | @wraps(f) 79 | def fun(*args, **kwargs): 80 | if 'admin-sign-id' in session: 81 | return f(*args, **kwargs) 82 | return redirect(url_for("Admin.admin_login")) 83 | return fun 84 | -------------------------------------------------------------------------------- /app/static/css/statements.css: -------------------------------------------------------------------------------- 1 | #statements { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | #statements > .statement { 7 | display: flex; 8 | flex-direction: row; 9 | flex-wrap: wrap; 10 | align-items: center; 11 | justify-content: space-between; 12 | margin: 10px 2.5px; 13 | padding: 20px 15px; 14 | box-shadow: 0px 0px 1px 0px var(--fg1); 15 | border-radius: 5px; 16 | width: 100%; 17 | max-width: calc(100% - 5px - 30px) !important; 18 | overflow: auto; 19 | } 20 | 21 | #statements > .statement, 22 | #statements > .statement * { 23 | background-color: #424242; 24 | text-decoration: none; 25 | } 26 | 27 | #statements > .statement:hover { 28 | box-shadow: 0px 0px 2px 0px var(--fg1); 29 | } 30 | 31 | #statements > .statement .desc { 32 | font-size: 22.5px; 33 | margin-bottom: 2.5px; 34 | } 35 | 36 | #statements > .statement .at { 37 | font-size: 12.5px; 38 | margin-top: 5px; 39 | } 40 | 41 | #statements > .statement .amount { 42 | font-size: 22.5px; 43 | margin-top: 15px; 44 | width: auto; 45 | text-align: center; 46 | } 47 | 48 | @media (max-width:650px) { 49 | #statements > .statement { 50 | flex-direction: column; 51 | align-items: flex-start; 52 | } 53 | 54 | #statements > .statement .amount { 55 | align-self: flex-end; 56 | 57 | } 58 | } 59 | 60 | .details-e { 61 | display: flex; 62 | flex-direction: row; 63 | flex-wrap: wrap; 64 | width: 100%; 65 | justify-content: space-between; 66 | margin: 50px 0px; 67 | } 68 | 69 | .details-e > .part-d { 70 | padding: 25px; 71 | border: 0px; 72 | box-shadow: 0px 0px 1px 0px var(--fg1); 73 | border-radius: 5px; 74 | margin: 10px; 75 | max-width: calc( 100% - 70px); 76 | overflow: hidden; 77 | min-width: calc(100% / 2.5); 78 | } 79 | 80 | .details-e > .part-d, 81 | .details-e > .part-d * { 82 | background-color: #272727; 83 | } 84 | 85 | .details-e > .part-d > .head { 86 | font-size: 20px; 87 | margin: 10px 3px; 88 | } 89 | 90 | .details-e > .part-d > .money { 91 | font-size: 30px; 92 | margin: 10px 3px; 93 | } 94 | 95 | .details-e > .part-d > .money > span { 96 | margin: 2px 3px; 97 | } 98 | 99 | #type { 100 | font-size: 17px; 101 | padding: 20px 30px; 102 | background-color: #262626; 103 | width: 100%; 104 | margin-bottom: 25px; 105 | border-radius: 50px; 106 | text-align: center; 107 | } 108 | 109 | @media (max-width:770px) { 110 | .details-e { 111 | flex-direction: column; 112 | } 113 | } -------------------------------------------------------------------------------- /app/views/settings.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, request, flash, redirect, url_for 2 | from werkzeug.security import generate_password_hash, check_password_hash 3 | 4 | from ..forms import SettingsForm1, SettingsForm2, SettingsForm3 5 | from ..db import db 6 | from ..app_functions import get_current_user, user_login_required 7 | 8 | 9 | settings = Blueprint("Settings", __name__, url_prefix="/settings") 10 | 11 | 12 | @settings.route("/", methods=("GET","POST")) 13 | @user_login_required 14 | def settings_index(): 15 | form1 = SettingsForm1(request.form) 16 | form2 = SettingsForm2(request.form) 17 | form3 = SettingsForm3(request.form) 18 | 19 | if form1.validate_on_submit(): 20 | name = form1.name.data.strip() 21 | 22 | user = get_current_user() 23 | user.name = name 24 | db.session.commit() 25 | 26 | flash("Name was changed successfully", "green") 27 | return redirect(url_for(".settings_index")) 28 | 29 | elif form3.validate_on_submit(): 30 | password = form3.password.data 31 | new_password = form3.new_password.data 32 | 33 | update_password = form3.update_password.data 34 | delete_account = form3.delete_account.data 35 | 36 | user = get_current_user() 37 | 38 | if check_password_hash(user.password, password): 39 | if update_password: 40 | if password == new_password: 41 | flash("Could not update the password as same as old password!","red") 42 | else: 43 | user.password = generate_password_hash(new_password) 44 | db.session.commit() 45 | flash("Password updated successfully","green") 46 | 47 | elif delete_account: 48 | if password != new_password: 49 | flash("current and confirm password does not matched!") 50 | else: 51 | db.session.delete(user) 52 | db.session.commit() 53 | flash("Account deleted successfully.") 54 | 55 | else: 56 | flash("cannot process your request, tryagain", "red") 57 | return redirect(url_for("Auth.auth_index")) 58 | else: 59 | flash("Incorrect password to perform operation!","red") 60 | return redirect(url_for(".settings_index")) 61 | 62 | user = get_current_user() 63 | 64 | form1.name.data = user.name 65 | form2.email.data = user.email 66 | 67 | return render_template( 68 | "settings/index.html", 69 | title="Settings", 70 | form1=form1, 71 | form2=form2, 72 | form3=form3, 73 | footer=True 74 | ) -------------------------------------------------------------------------------- /app/functions.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import hashlib 3 | import random 4 | import uuid 5 | import string 6 | import base64 7 | import typing 8 | import io 9 | from matplotlib.figure import Figure 10 | from matplotlib.axes import Axes 11 | 12 | def generate_id(name, email, hashed_password, limit=16): 13 | req = "--".join([name, email, hashed_password, str(datetime.datetime.now())]) 14 | req += uuid.uuid4().hex 15 | return hashlib.sha1("".join(random.choice(req) for _ in range(limit)).encode()).hexdigest() 16 | 17 | 18 | def generate_string(limit:int = 12): 19 | data = str(datetime.datetime.now()) 20 | uuid4 = uuid.uuid4().hex 21 | confirm_data = string.ascii_letters+data+uuid4 22 | return "".join(random.choice(confirm_data) for _ in range(limit)) 23 | 24 | 25 | def get_base64_encode(buffer:str): 26 | if isinstance(buffer, str): 27 | buffer = buffer.encode() 28 | return base64.urlsafe_b64encode(buffer).decode() 29 | 30 | def get_base64_decode(buffer): 31 | try: 32 | return base64.urlsafe_b64decode(buffer).decode() 33 | except ValueError: 34 | return "" 35 | 36 | 37 | def get_pie_chart(data:list[tuple[str,typing.Union[int, float]]], title:str): 38 | pie_data = [x[1] for x in data] 39 | 40 | percentages = [x*100/sum(pie_data) for x in pie_data] 41 | labels = [f"{data[i][0]}({data[i][1]}) - {percentages[i]:.2f}%" for i in range(len(data))] 42 | explode = [0.001*x for x in percentages] 43 | 44 | data_buf = io.BytesIO() 45 | fig = Figure(figsize=(10,4)) 46 | 47 | ax = fig.subplots() 48 | ax.pie( 49 | pie_data, 50 | explode=explode, 51 | startangle=90, 52 | ) 53 | ax.set_title(title) 54 | 55 | fig.tight_layout() 56 | fig.legend(labels=labels, loc="lower left",) 57 | 58 | fig.savefig(data_buf, format="png", transparent=True) 59 | data_buf.seek(0) 60 | return base64.b64encode(data_buf.read()).decode("utf-8") 61 | 62 | 63 | def get_line_chart(data:list[tuple[str,typing.Union[float, int]]], xlabel:str, ylabel:str, title:str): 64 | fig = Figure(figsize=(10,4)) 65 | 66 | data_buf = io.BytesIO() 67 | 68 | ax:Axes = fig.subplots() 69 | ax.plot( 70 | [x[0] for x in data], 71 | [x[1] for x in data], 72 | ) 73 | ax.set_title(title) 74 | ax.set_xlabel(xlabel) 75 | ax.set_ylabel(ylabel) 76 | fig.tight_layout() 77 | 78 | fig.savefig(data_buf, transparent=True) 79 | 80 | data_buf.seek(0) 81 | return base64.b64encode(data_buf.read()).decode("utf-8") 82 | 83 | 84 | def get_bar_chart(data:list[tuple[str,typing.Union[float, int]]], xlabel:str, ylabel:str, title:str): 85 | fig = Figure(figsize=(10,4)) 86 | 87 | data_buf = io.BytesIO() 88 | 89 | ax:Axes = fig.subplots() 90 | ax.bar( 91 | [x[0] for x in data], 92 | [x[1] for x in data], 93 | ) 94 | ax.set_title(title) 95 | ax.set_xlabel(xlabel) 96 | ax.set_ylabel(ylabel) 97 | fig.tight_layout() 98 | 99 | fig.savefig(data_buf, transparent=True) 100 | 101 | data_buf.seek(0) 102 | return base64.b64encode(data_buf.read()).decode("utf-8") -------------------------------------------------------------------------------- /app/templates/admin/users.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block body %} 4 |
5 |

{{page_title}} data

6 |
7 | 8 |
9 |
Total users : {{ users_count }}
10 |
Users per page : {{ users_per_page }}
11 |
Page count : {{ page_count }}
12 |
13 | Change users per page 14 |
15 |
16 |
17 | 18 | 19 |
20 | 21 |
22 |
23 |
24 | 25 |
26 | Search user 27 |
28 |
29 | 30 |
31 | 32 | 33 |
34 | 35 |
36 |
37 |
38 |
39 |
40 | 41 | {% if users %} 42 | 43 | 44 | {% if page_title == "Admin" %} 45 | 46 | {% else %} 47 | 48 | {% endif %} 49 | 50 | 51 | 52 | 53 | 54 | {% for user in users %} 55 | 56 | {% if page_title == "Admin" %} 57 | 58 | {% else %} 59 | 60 | {% endif %} 61 | 62 | 63 | 66 | 67 | {% endfor %} 68 |
UsernameNameEmailSession idActions
{{user.username}}{{user.name}}{{user.email}}{{user.session_id}} 64 | Edit 65 |
69 |
70 | {% if prev_btn %} 71 | Previous page 72 | {% endif %} 73 | {% if next_btn %} 74 | Next page 75 | {% endif %} 76 |
77 | {% else %} 78 |
79 | User(s) doesn't exists on this page 80 |
81 | {% endif %} 82 |
83 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/settings/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block head %} 4 | 5 | {% endblock %} 6 | 7 | {% block body %} 8 |
9 |
10 | << Back to Home 11 |

Settings

12 | 13 |
14 | {{ form1.csrf_token() }} 15 |
16 | 17 |
18 |

Name

19 | {{ form1.name.label }} 20 | {{ form1.name() }} 21 | {% if form1.name.errors %} 22 |
23 | {% for error in form1.name.errors %} 24 |

{{error}}

25 | {% endfor %} 26 |
27 | {% endif %} 28 |
29 | {{ form1.update_name() }} 30 |
31 | 32 |
33 | 34 |
35 |
36 |

Email

37 | {{ form2.email.label }} 38 | {{ form2.email() }} 39 |
40 |
41 | 42 |
43 | 44 |
45 | {{ form3.csrf_token() }} 46 | 47 |
48 |

Update password

49 |
50 | 51 |
52 | {{ form3.password.label }} 53 | {{ form3.password() }} 54 | {% if form3.password.errors %} 55 |
56 | {% for error in form3.password.errors %} 57 |

{{error}}

58 | {% endfor %} 59 |
60 | {% endif %} 61 |
62 |
63 | {{ form3.new_password.label }} 64 | {{ form3.new_password() }} 65 | {% if form3.new_password.errors %} 66 |
67 | {% for error in form3.new_password.errors %} 68 |

{{error}}

69 | {% endfor %} 70 |
71 | {% endif %} 72 |
73 |
74 | {{ form3.update_password() }} 75 | {{ form3.delete_account() }} 76 |
77 | 78 |
79 | 80 |
81 | 82 |
83 |
84 |

Logout from this device

85 |
86 | 87 | 88 |
89 |
90 |
91 | {% endblock %} -------------------------------------------------------------------------------- /app/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms.validators import Email, Length, DataRequired,NumberRange,EqualTo 3 | from wtforms.fields import EmailField, PasswordField, SubmitField, TextAreaField, FloatField, DateTimeLocalField, StringField, SelectField 4 | 5 | 6 | class AuthForm(FlaskForm): 7 | email = EmailField( 8 | "Your email", 9 | [ 10 | DataRequired("Email is required!"), 11 | Email("Enter a valid email!"), 12 | Length( 13 | max=95, 14 | message="Email should not contain more than 95 characters!" 15 | ) 16 | ] 17 | ) 18 | 19 | password = PasswordField( 20 | "Password", 21 | [ 22 | DataRequired("Password is required!"), 23 | Length( 24 | 8, 30, "Password should contain 8 to 30 characters." 25 | ) 26 | ] 27 | ) 28 | 29 | submit = SubmitField( 30 | "Log in" 31 | ) 32 | 33 | class NewStatementForm(FlaskForm): 34 | amount = FloatField( 35 | "Enter amount", 36 | [ 37 | DataRequired("Amount is required!"), 38 | NumberRange(0.0001,9999999999.99, "Entered amount could not be added to your statement!") 39 | ] 40 | ) 41 | description = TextAreaField( 42 | "Enter description", 43 | [ 44 | DataRequired("Description is required!"), 45 | Length(5,180,"Description can contain 5 to 180 characters!") 46 | ], 47 | render_kw={ 48 | "class" : "textarea-h", 49 | "rows" : "2" 50 | } 51 | ) 52 | 53 | datetime_data = DateTimeLocalField( 54 | format="%Y-%m-%dT%H:%M", 55 | validators=[ 56 | DataRequired("This field is required!") 57 | ] 58 | ) 59 | 60 | expense = SubmitField("Add Expense") 61 | income = SubmitField("Add Income") 62 | 63 | 64 | class SettingsForm1(FlaskForm): 65 | name = StringField( 66 | "Your name", 67 | [ 68 | DataRequired("Cannot set empty string as the name!"), 69 | Length( 70 | min=5, max=45, 71 | message="Name can contain 5 to 45 characters." 72 | ) 73 | ] 74 | ) 75 | 76 | update_name = SubmitField("Update name") 77 | 78 | class SettingsForm2(FlaskForm): 79 | email = EmailField( 80 | "Your email", 81 | render_kw={ 82 | "disabled" : "true" 83 | } 84 | ) 85 | 86 | class SettingsForm3(FlaskForm): 87 | password = PasswordField( 88 | "Current password", 89 | [ 90 | DataRequired("Current password is required!"), 91 | Length( 92 | 8, 30, "Password should contain 8 to 30 characters." 93 | ) 94 | ] 95 | ) 96 | 97 | new_password = PasswordField( 98 | "New/Confirm password", 99 | [ 100 | DataRequired("New/Confirm Password password is required!"), 101 | Length( 102 | 8, 30, "Password should contain 8 to 30 characters." 103 | ), 104 | # not EqualTo("password", "Password should not match with older password!") 105 | ] 106 | ) 107 | update_password = SubmitField("Update password") 108 | 109 | delete_account = SubmitField("Delete account", render_kw={ 110 | "class" : "delete" 111 | }) 112 | 113 | class StatementEditForm(NewStatementForm): 114 | expense = SubmitField("Update as Expense") 115 | income = SubmitField("Update as Income") 116 | delete_statement = SubmitField("Delete Statement", render_kw={ 117 | "class" : "delete" 118 | }) 119 | 120 | 121 | 122 | class EditUserForm(SettingsForm1, AuthForm): 123 | update_name = None 124 | submit = None 125 | 126 | password = PasswordField( 127 | "Password to update", 128 | [ 129 | Length( 130 | max=30, message="Password should contain 8 to 30 characters." 131 | ) 132 | ] 133 | ) 134 | 135 | update_account = SubmitField( 136 | "Update data" 137 | ) 138 | 139 | delete_account = SubmitField( 140 | "Delete account", render_kw={ 141 | "class" : "delete" 142 | }) 143 | 144 | def __init__(self, formdata=..., **kwargs): 145 | super().__init__(formdata, **kwargs) 146 | self.name.label = "Name" 147 | self.email.label = "Email" -------------------------------------------------------------------------------- /app/static/css/main.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "JosefinSans-Medium"; 3 | src: url("/static/fonts/JosefinSans-Medium.ttf"); 4 | } 5 | 6 | :root { 7 | --bg : #222; 8 | --shade : #999; 9 | --fg : rgb(0, 235, 90); 10 | --fg1 : #fff; 11 | --font : "JosefinSans-Medium"; 12 | --font-s : "JosefinSans-Medium"; 13 | } 14 | 15 | * { 16 | background-color: var(--bg); 17 | color: var(--fg1); 18 | font-family: var(--font); 19 | } 20 | 21 | body { 22 | padding: 0px; 23 | margin: 0px; 24 | } 25 | 26 | .header { 27 | font-size: 19.5px; 28 | color: var(--fg); 29 | box-shadow: 0px 0px 1px 0px var(--fg1); 30 | padding: 15px; 31 | text-align: center; 32 | } 33 | 34 | .messages-s { 35 | margin: 10px 0px; 36 | } 37 | 38 | .messages-s > .msg { 39 | font-size: 17px; 40 | margin: 5px 0px; 41 | padding: 10px; 42 | color: #fff; 43 | border: 5px solid #fff; 44 | border-left: 0px; 45 | border-right: 0px; 46 | width: 100vw; 47 | max-width: calc( 100% - 20px ); 48 | display: flex; 49 | justify-content: space-between; 50 | align-items: center; 51 | } 52 | 53 | .messages-s > .msg-green { 54 | border-color: rgb(0, 192, 74); 55 | } 56 | 57 | .messages-s > .msg-red { 58 | border-color: rgb(250, 43, 32); 59 | } 60 | 61 | .messages-s .end-btn { 62 | border: 0px; 63 | padding: 0px; 64 | margin: 2px 10px; 65 | font-size: 26.5px; 66 | } 67 | 68 | .form { 69 | box-shadow: 0px 0px 1px 0px var(--fg1); 70 | width: 350px; 71 | max-width: calc(100% - 50px); 72 | padding: 25px; 73 | margin: 25px auto; 74 | } 75 | 76 | .form > form, 77 | .form > form > .label { 78 | display: flex; 79 | flex-direction: column; 80 | } 81 | 82 | .form > form > .label { 83 | margin: 15px 5px; 84 | } 85 | 86 | .form > form > .label > label { 87 | font-size: 16px; 88 | margin: 2px 0px; 89 | } 90 | 91 | .form > form > .label > input, 92 | .form > form > .label > select, 93 | .form > form > .label > textarea { 94 | font-size: 18.5px; 95 | padding: 10px; 96 | border: 1px solid var(--fg1); 97 | outline: none; 98 | border-radius: 2px; 99 | } 100 | 101 | .form > form > .label > input:focus , 102 | .form > form > .label > textarea:focus { 103 | box-shadow: 0px 0px 0px 2px var(--fg); 104 | border: 1px solid transparent; 105 | } 106 | 107 | .form > form input[type="submit"], .btn{ 108 | padding: 7px 10px; 109 | font-size: 17px; 110 | margin: 10px 2px; 111 | background-color: var(--bg); 112 | color: var(--fg); 113 | border: 1.5px solid var(--fg); 114 | border-radius: 3px; 115 | transition: 200ms; 116 | } 117 | 118 | .form > form input:disabled { 119 | border: 2px solid var(--shade); 120 | color: var(--shade); 121 | } 122 | 123 | .form > form input[type="submit"]:hover, 124 | .form > form input[type="submit"]:focus, 125 | .btn:hover, .btn:focus { 126 | color: var(--bg); 127 | background-color: var(--fg); 128 | transition: 150ms; 129 | cursor: pointer; 130 | } 131 | 132 | .form > form > .label > .errors { 133 | margin: 3px 0px; 134 | } 135 | 136 | .form > form > .label > .errors > p { 137 | margin: 2px 0px; 138 | } 139 | 140 | .form > form > .label > .errors > p::before { 141 | content: "*"; 142 | } 143 | 144 | .form .note { 145 | font-size: 15px; 146 | } 147 | 148 | .shadow-card { 149 | padding: 20px; 150 | margin: 20px auto; 151 | width: calc(100% - 50px); 152 | max-width: 850px; 153 | border-radius: 10px; 154 | } 155 | 156 | .shadow-card, 157 | .shadow-card * { 158 | background-color: #333; 159 | } 160 | 161 | .flex { 162 | display: flex; 163 | flex-direction: column; 164 | } 165 | 166 | .flex-s { 167 | display: flex; 168 | flex-direction: row; 169 | justify-content: space-between; 170 | align-items: center; 171 | } 172 | 173 | .w-btn { 174 | color: #fff !important; 175 | border-color: #fff !important; 176 | font-size: 17px; 177 | padding: 15px 30px; 178 | } 179 | 180 | .w-btn:hover, .w-btn:focus { 181 | background-color: #fff !important; 182 | color: #000 !important; 183 | } 184 | 185 | .footer { 186 | width: calc( 100% - 100px ); 187 | border-top: 0.5px solid var(--fg1); 188 | padding: 50px; 189 | margin-top: 50px; 190 | } 191 | 192 | .footer > footer { 193 | max-width: 1250px; 194 | margin: auto; 195 | display: flex; 196 | flex-wrap: wrap; 197 | flex-direction: row; 198 | justify-content: space-between; 199 | } 200 | 201 | .footer > footer > .part { 202 | margin: 10px 20px; 203 | } 204 | 205 | .footer > footer > .part > .head { 206 | font-size: 25px; 207 | } 208 | 209 | 210 | form .delete { 211 | border-color: rgb(255, 19, 19) !important; 212 | color: rgb(255, 43, 43) !important; 213 | } 214 | 215 | form .delete:hover, 216 | form .delete:focus { 217 | background-color: rgb(255, 19, 19) !important; 218 | color: #fff !important; 219 | } -------------------------------------------------------------------------------- /app/templates/home/statements.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block head %} 4 | 5 | 6 | 7 | 74 | {% endblock %} 75 | 76 | {% block body %} 77 |
78 |
79 | << Back to Home 80 |
81 |

Statements

82 |
83 |
84 |
Total Income
85 |
{{round(total_income,3)}}
86 |
87 |
88 |
Total Expense
89 |
{{round(total_expense,3)}}
90 |
91 |
92 |
Current balance
93 |
{{round(current_balance,3)}}
94 |
95 | Download statements 96 |
97 | 98 |
99 | 104 |
105 | 106 |
107 | {% if statements %} 108 | {% for statement in statements %} 109 | 110 |
111 | {{statement.statement_id}} 112 |
{{statement.desc}}
113 |
{{statement.at}}
114 |
115 |
₹{{statement.amount}}
116 |
117 | {% endfor %} 118 | 119 | {% else %} 120 |

You've not added any statements yet.

121 | {% endif %} 122 |
123 |
124 | 125 | 132 | {% endblock %} -------------------------------------------------------------------------------- /app/views/home.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, request, render_template, redirect, url_for, flash, Response, stream_with_context 2 | 3 | from ..app_functions import get_current_user, get_current_user_balance, user_login_required 4 | from ..functions import generate_string 5 | from ..forms import NewStatementForm, StatementEditForm 6 | from ..db import db, Statements, User 7 | from sqlalchemy import func 8 | 9 | 10 | home = Blueprint("Home", __name__) 11 | 12 | 13 | @home.route("/") 14 | @user_login_required 15 | def home_index(): 16 | user = get_current_user() 17 | 18 | user_details = {} 19 | user_details["name"] = user.name 20 | user_details["account_balance"] = round(get_current_user_balance(),2) 21 | 22 | statements = Statements.query.filter(Statements.user_id == user.id).order_by(Statements.operation_time.desc()).limit(5).all() 23 | 24 | user_details["statements"] = list( 25 | {"desc":x.description, "amount":x.amount, "at":x.operation_time, "id": x.statement_id} for x in statements 26 | ) 27 | 28 | return render_template( 29 | "home/index.html", 30 | user= user_details 31 | ) 32 | 33 | @home.route("/new", methods=("GET","POST")) 34 | @user_login_required 35 | def new_statement(): 36 | form = NewStatementForm(request.form) 37 | if form.validate_on_submit(): 38 | amount = form.amount.data 39 | description = form.description.data 40 | at = form.datetime_data.data 41 | income = form.income.data 42 | expense = form.expense.data 43 | 44 | if not income and not expense: 45 | flash("Cannot add statement that is neither income nor expense!","red") 46 | return redirect(url_for(".new_statement")) 47 | 48 | amount = abs(amount) 49 | if expense is True: 50 | amount = -amount 51 | 52 | current_user = get_current_user() 53 | user_id = current_user.id 54 | 55 | statement = Statements( 56 | description = description, 57 | amount = amount, 58 | operation_time = at, 59 | user_id = user_id, 60 | statement_id = generate_string() 61 | ) 62 | 63 | db.session.add(statement) 64 | db.session.commit() 65 | 66 | flash("Statement was added to your account successfully.", "green") 67 | return redirect(url_for("Home.home_index")) 68 | 69 | return render_template( 70 | "home/new_statement.html", 71 | title="New statement", 72 | form=form 73 | ) 74 | 75 | @home.route("/statements") 76 | @user_login_required 77 | def statements(): 78 | try: 79 | page = int(request.args.get("page","0")) 80 | except: 81 | page = 0 82 | finally: 83 | if page < 0: 84 | page = 0 85 | 86 | page_size = 6 87 | 88 | user = get_current_user() 89 | 90 | if request.args.get("t") == "expense": 91 | statements = Statements.query.filter(Statements.user_id == user.id, Statements.amount < 0).order_by(Statements.operation_time.desc()).limit(page_size).offset(page_size*page).all() 92 | elif request.args.get("t") == "income": 93 | statements = Statements.query.filter(Statements.user_id == user.id, Statements.amount >= 0).order_by(Statements.operation_time.desc()).limit(page_size).offset(page_size*page).all() 94 | else: 95 | statements = Statements.query.filter(Statements.user_id == user.id).order_by(Statements.operation_time.desc()).limit(page_size).offset(page_size*page).all() 96 | 97 | statements = list( 98 | {"desc":x.description, "amount":x.amount, "at": str(x.operation_time), "id": x.statement_id} for x in statements 99 | ) 100 | 101 | current_balance = get_current_user_balance() 102 | total_expense = Statements.query.with_entities(func.sum(Statements.amount)).filter(Statements.user_id == user.id, Statements.amount < 0).first()[0] 103 | if total_expense is None: 104 | total_expense = 0.0 105 | total_expense = abs(total_expense) 106 | total_income = Statements.query.with_entities(func.sum(Statements.amount)).filter(Statements.user_id == user.id, Statements.amount >= 0).first()[0] 107 | if total_income is None: 108 | total_income = 0.0 109 | 110 | if request.args.get("__a") == "1": 111 | return statements 112 | 113 | date = [] 114 | expense_amount = [] 115 | income_amount = [] 116 | amount = [] 117 | 118 | for x in Statements.query.filter(Statements.user_id == user.id).order_by(Statements.operation_time).all(): 119 | date.append(x.operation_time) 120 | money = x.amount 121 | 122 | amount.append(abs(money)) 123 | 124 | if money < 0: 125 | expense_amount.append(abs(money)) 126 | income_amount.append(None) 127 | else: 128 | income_amount.append(money) 129 | expense_amount.append(None) 130 | 131 | 132 | return render_template( 133 | "home/statements.html", 134 | title="All statements", 135 | statements=statements, 136 | total_income=total_income, 137 | total_expense=total_expense, 138 | current_balance=current_balance, 139 | page_size=page_size, 140 | round=round 141 | ) 142 | 143 | @home.route("/statement/", methods=("GET","POST")) 144 | @user_login_required 145 | def specific_statement(statement_id): 146 | statement = Statements.query.filter(Statements.statement_id == statement_id).first_or_404() 147 | 148 | form = StatementEditForm(request.form) 149 | 150 | if form.validate_on_submit(): 151 | amount = form.amount.data 152 | description = form.description.data 153 | date_time = form.datetime_data.data 154 | 155 | income = form.income.data 156 | expense = form.expense.data 157 | delete = form.delete_statement.data 158 | 159 | amount = abs(amount) 160 | 161 | if income is True: 162 | statement.amount = amount 163 | statement.description = description 164 | statement.operation_time = date_time 165 | 166 | db.session.commit() 167 | 168 | flash("Updated statement as income successfully", "green") 169 | 170 | elif expense is True: 171 | statement.amount = -amount 172 | statement.description = description 173 | statement.operation_time = date_time 174 | 175 | db.session.commit() 176 | 177 | flash("Updated statement as expense successfully", "green") 178 | 179 | elif delete is True: 180 | db.session.delete(statement) 181 | db.session.commit() 182 | 183 | flash("The statement was deleted successfully", "green") 184 | 185 | return redirect(url_for(".home_index")) 186 | 187 | else: 188 | flash("Unsupported action to perform!", "red") 189 | 190 | return redirect(url_for(".specific_statement", statement_id=statement_id)) 191 | 192 | form.amount.data = abs(statement.amount) 193 | form.description.data = statement.description 194 | form.datetime_data.data = statement.operation_time 195 | 196 | 197 | return render_template( 198 | "home/statement.html", 199 | title="Statement", 200 | form=form, 201 | statement_id=statement_id 202 | ) 203 | 204 | @home.route("/download-statements") 205 | @user_login_required 206 | def download_statements(): 207 | user = get_current_user() 208 | statements = Statements.query.filter(Statements.user_id == user.id) 209 | 210 | content_disposition = "attachment; filename={}'s-statements.csv".format(user.name) 211 | 212 | def data(): 213 | first_iter = True 214 | for statement in statements.yield_per(5): 215 | if first_iter is True: 216 | first_iter = False 217 | yield "amount, description, datetime, type\n" 218 | 219 | if statement.amount < 0: 220 | statement_type = "expense" 221 | else: 222 | statement_type = "income" 223 | yield f"{abs(statement.amount)},{statement.description},{statement.operation_time.isoformat()},{statement_type}\n" 224 | 225 | return Response(stream_with_context(data()), 200, mimetype="text/csv",headers={ 226 | "Content-Disposition" : content_disposition 227 | }) -------------------------------------------------------------------------------- /app/views/admin.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template, request, flash, redirect, url_for, session, abort 2 | from werkzeug.security import check_password_hash, generate_password_hash 3 | from ..db import Admin, User, Statements, VisitorStats, db 4 | from sqlalchemy import func, desc, cast, Date 5 | from ..forms import AuthForm, EditUserForm 6 | from ..functions import get_pie_chart, get_line_chart 7 | from ..app_functions import admin_login_required 8 | import math 9 | 10 | admin = Blueprint("Admin",__name__, url_prefix="/admin") 11 | 12 | @admin.route("/") 13 | @admin_login_required 14 | def dashboard(): 15 | if 'admin-sign-id' not in session: 16 | return redirect(url_for(".admin_login")) 17 | 18 | admin = Admin.query.filter(Admin.session_id == session.get("admin-sign-id")).first_or_404() 19 | 20 | name = admin.username 21 | 22 | users_count = User.query.with_entities(func.count(User.id)).first()[0] 23 | statements_count = Statements.query.with_entities(func.count(Statements.id)).first()[0] 24 | 25 | visits_count = VisitorStats.query.with_entities(func.count(VisitorStats.browser)).first()[0] 26 | bots_visits = VisitorStats.query.filter(VisitorStats.is_bot == True).with_entities(func.count(VisitorStats.is_bot)).first()[0] 27 | other_user_agent_visits = VisitorStats.query.filter(VisitorStats.browser == "other").with_entities(func.count(VisitorStats.browser)).first()[0] 28 | 29 | most_used_useragent = VisitorStats.query.with_entities(VisitorStats.browser, func.count(VisitorStats.browser).label("browser_count")).group_by(VisitorStats.browser).order_by(desc("browser_count")).limit(1).first()[0] 30 | most_used_operating_system = VisitorStats.query.with_entities(VisitorStats.operating_system, func.count(VisitorStats.operating_system).label("os")).group_by(VisitorStats.operating_system).order_by(desc("os")).limit(1).first()[0] 31 | highest_visits_with_date = VisitorStats.query.with_entities(cast(VisitorStats.date, Date), func.count(cast(VisitorStats.date, Date)).label("date")).group_by(cast(VisitorStats.date, Date)).order_by(desc("date")).limit(1).first() 32 | 33 | less_used_useragent = VisitorStats.query.with_entities(VisitorStats.browser, func.count(VisitorStats.browser).label("browser_count")).group_by(VisitorStats.browser).order_by("browser_count").limit(1).first()[0] 34 | less_used_operating_system = VisitorStats.query.with_entities(VisitorStats.operating_system, func.count(VisitorStats.operating_system).label("os")).group_by(VisitorStats.operating_system).order_by("os").limit(1).first()[0] 35 | lowest_visits_with_date = VisitorStats.query.with_entities(cast(VisitorStats.date, Date), func.count(cast(VisitorStats.date, Date)).label("date")).group_by(VisitorStats.date).order_by("date").limit(1).first() 36 | 37 | browser_data = VisitorStats.query.with_entities(VisitorStats.browser, func.count(VisitorStats.browser)).group_by(VisitorStats.browser).all() 38 | browser_pie_chart = get_pie_chart(browser_data, "Browser used by visitors") 39 | 40 | operating_system_data = VisitorStats.query.with_entities(VisitorStats.operating_system, func.count(VisitorStats.operating_system)).group_by(VisitorStats.operating_system).all() 41 | operating_system_chart = get_pie_chart(operating_system_data, "Operating System of visitors") 42 | 43 | visits_data = VisitorStats.query.with_entities(cast(VisitorStats.date, Date), func.count(VisitorStats.date)).group_by(cast(VisitorStats.date, Date)).order_by(desc(cast(VisitorStats.date, Date))).limit(90).all() 44 | visits_line_chart = get_line_chart(visits_data, "Visit date", "Traffic amount", "Application visits statistics (last active 90 days)") 45 | 46 | return render_template( 47 | "admin/index.html", 48 | title="Admin : Dashboard : {}".format(name), 49 | name=name, 50 | table_data = { 51 | "Total users" : users_count, 52 | "Total Statements" : statements_count, 53 | "Average statements" : statements_count/users_count, 54 | "Total visits" : visits_count, 55 | "Bot(s) visits" : bots_visits, 56 | "Other user-agent visits" : other_user_agent_visits, 57 | "Total visits - Bot(s)" : visits_count - bots_visits, 58 | "Total visits - Bot(s) - Other(s)" : visits_count - bots_visits - other_user_agent_visits, 59 | "Most used user agent" : most_used_useragent, 60 | "Less used user agent" : less_used_useragent, 61 | "Most used OS" : most_used_operating_system, 62 | "Less used OS" : less_used_operating_system, 63 | "Highest hits date(count)" : f"{highest_visits_with_date[0]}({highest_visits_with_date[1]})", 64 | "Lowest hits date(count)" : f"{lowest_visits_with_date[0]}({lowest_visits_with_date[1]})", 65 | }, 66 | browser_pie_chart=browser_pie_chart, 67 | visits_line_chart=visits_line_chart, 68 | operating_system_chart=operating_system_chart 69 | ) 70 | 71 | 72 | @admin.route("/login", methods=("GET","POST")) 73 | def admin_login(): 74 | form = AuthForm(request.form) 75 | 76 | if form.validate_on_submit(): 77 | email = form.email.data 78 | password = form.password.data 79 | 80 | account = Admin.query.filter(Admin.email == email).first() 81 | 82 | if not account: 83 | flash("Admin account does not exists!","red") 84 | elif not check_password_hash(account.password, password): 85 | flash("Invalid password for admin account!","red") 86 | else: 87 | session["admin-sign-id"] = account.session_id 88 | return redirect(url_for(".dashboard")) 89 | 90 | return redirect(url_for(".admin_login")) 91 | 92 | return render_template( 93 | "admin/login.html", 94 | title="Admin Login", 95 | form=form, 96 | is_loggedin=False 97 | ) 98 | 99 | 100 | @admin.route("/users") 101 | @admin_login_required 102 | def users(): 103 | page = request.args.get("page", "1") 104 | users_per_page = request.args.get("upp","10") 105 | 106 | if not page.isnumeric() or int(page) <= 0: 107 | page = "1" 108 | page = int(page)-1 109 | 110 | if not users_per_page.isnumeric() or int(users_per_page) <= 0: 111 | users_per_page = "10" 112 | users_per_page = int(users_per_page) 113 | 114 | users_type = request.args.get("type","none").lower() 115 | if users_type not in ("user","admin"): 116 | users_type = "user" 117 | 118 | if users_type == "user": 119 | users = User.query.limit(users_per_page).offset(page*users_per_page).all() 120 | users_count = User.query.with_entities(func.count(User.id)).first()[0] 121 | page_count = math.ceil(users_count/users_per_page) 122 | else: 123 | users = Admin.query.limit(users_per_page).offset(page*users_per_page).all() 124 | users_count = Admin.query.with_entities(func.count(Admin.id)).first()[0] 125 | page_count = math.ceil(users_count/users_per_page) 126 | 127 | 128 | return render_template( 129 | "admin/users.html", 130 | title="Admin : users : {}".format(users_type.title()), 131 | users=users, 132 | users_count=users_count, 133 | users_per_page=users_per_page, 134 | page_count=page_count, 135 | current_page=page, 136 | prev_btn=page > 0, 137 | next_btn=page+1 < page_count, 138 | page_title=users_type.title() 139 | ) 140 | 141 | 142 | @admin.route("/user///", methods=["GET","POST"]) 143 | @admin_login_required 144 | def specific_user(member_type,id,email): 145 | member_type = member_type.lower() 146 | if member_type not in ("user", "admin"): 147 | return abort(404) 148 | 149 | if member_type == "user": 150 | user = User.query.filter(User.id == id, User.email == email).first() 151 | else: 152 | user = Admin.query.filter(Admin.id == id, Admin.email == email).first() 153 | 154 | if not user: 155 | return abort(404) 156 | 157 | form = EditUserForm(request.form) 158 | 159 | if form.validate_on_submit(): 160 | name = form.name.data 161 | email = form.email.data 162 | password = form.password.data 163 | update_account = form.update_account.data 164 | delete_account = form.delete_account.data 165 | 166 | if delete_account is True: 167 | db.session.delete(user) 168 | db.session.commit() 169 | 170 | flash("User deleted successfully", "green") 171 | 172 | elif update_account is True: 173 | if member_type == "user": 174 | user.name = name 175 | else: 176 | user.username = name 177 | user.email = email 178 | 179 | if password: 180 | user.password = generate_password_hash(password, "SHA256") 181 | 182 | db.session.commit() 183 | flash("User updated successfully", "green") 184 | 185 | else: 186 | flash("Operation is not supported!", "red") 187 | 188 | return redirect(url_for(".specific_user",member_type=member_type, email=email, id=id)) 189 | 190 | if member_type == "user": 191 | form.name.data = user.name 192 | else: 193 | form.name.data = user.username 194 | 195 | form.email.data = user.email 196 | 197 | return render_template( 198 | "admin/user.html", 199 | title="Details of user {}".format(form.name.data), 200 | form=form, 201 | ) 202 | 203 | @admin.route("/logout", methods=["POST"]) 204 | @admin_login_required 205 | def logout_admin(): 206 | session.pop("admin-sign-id") 207 | flash("Logged out successfully.", "green") 208 | return redirect(url_for(".admin_login")) --------------------------------------------------------------------------------