diff --git a/BABEL_USAGE.md b/BABEL_USAGE.md new file mode 100644 index 0000000..3b7e9d1 --- /dev/null +++ b/BABEL_USAGE.md @@ -0,0 +1,17 @@ +# Babel usage +Babel will scan and detect sections of code that uses `gettext` or `_` in HTML templates and python code. It takes the constant string passed into its function and makes a translation template which can then be used to build individual translations. + +## Usage +If you are compiling and building a template from scratch +``` +$ pybabel extract -F babel.cfg -o messages.pot ./ +$ pybabel update -i messages.pot -d translations +``` + +Make translations in the outputted file then compile the translation with +``` +$ pybabel compile -d translations +``` + +Make sure the `messages.po` file is in the right language directory. +Example path: `translations/nb/LC_MESSAGES/messages.po` diff --git a/README.md b/README.md index 753028a..ffb6939 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,13 @@ Install the required dependencies with pip ``` $ pip install -r requirements.txt ``` -then, run the web server +Make sure you compile the babel languages +``` +$ pybabel compile -d translations +``` +Lastly, run the web server ``` $ python3 main.py ``` -Configuration can be edited in config.json \ No newline at end of file +Configuration can be edited in config.json diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..0cc0bac --- /dev/null +++ b/babel.cfg @@ -0,0 +1,3 @@ +[python: **.py] +[jinja2: **/templates/**.html] +extensions=jinja2.ext.autoescape,jinja2.ext.with_ \ No newline at end of file diff --git a/default_config.json b/default_config.json index aa69d5d..614eac6 100644 --- a/default_config.json +++ b/default_config.json @@ -11,5 +11,12 @@ "auto_pull_and_restart": false, "webhook_endpoint": "/api/git_commit", "secret": "iOnlyHavePullAccess" + }, + "mysql": { + "host": "localhost", + "port": 3306, + "user": "husstanden", + "passwd": "", + "db": "db_husstanden" } } \ No newline at end of file diff --git a/filters.py b/filters.py deleted file mode 100644 index fb8cceb..0000000 --- a/filters.py +++ /dev/null @@ -1,8 +0,0 @@ -from flask import render_template -from objects import glob - -""" -@glob.app.template_filter('load_module') -def load_module(module_name): - return render_template("modules/%s.html" % module_name) -""" \ No newline at end of file diff --git a/forms/login.py b/forms/login.py new file mode 100644 index 0000000..14ba8a0 --- /dev/null +++ b/forms/login.py @@ -0,0 +1,122 @@ +from wtforms import Form, BooleanField, StringField, PasswordField, TextAreaField, validators +from wtforms.fields.html5 import DateField, DecimalField, IntegerField, EmailField +from wtforms.widgets import TextArea +from flask_login import UserMixin +from flask_babel import gettext as _ + +from objects import glob + +FORM_RENDER_KW = { + "class_": "form-control" +} + +class BillForm(Form): + payment_to = StringField(_("Payment to"), [validators.DataRequired()], render_kw = FORM_RENDER_KW) + description = TextAreaField(_("Description"), render_kw = { + "cols": 55, + "rows": 8, + **FORM_RENDER_KW + }) + sum = DecimalField(_("Sum"), render_kw = FORM_RENDER_KW) + kid = IntegerField(_("KID"), render_kw = FORM_RENDER_KW) + date_due = DateField(_("Date due"), render_kw = FORM_RENDER_KW) + +class WarrantyForm(Form): + item = StringField(_("Item"), [validators.DataRequired()], render_kw = FORM_RENDER_KW) + date_from = DateField(_("Date of purchase"), render_kw = FORM_RENDER_KW) + date_to = DateField(_("Warranty duration"), render_kw = FORM_RENDER_KW) + sum = DecimalField(_("Sum"), render_kw = FORM_RENDER_KW) + +class ServiceForm(Form): + name = StringField(_("Name"), [validators.DataRequired()], render_kw = FORM_RENDER_KW) + type = StringField(_("Type"), [validators.DataRequired()], render_kw = FORM_RENDER_KW) + contact = StringField(_("Contact"), render_kw = FORM_RENDER_KW) + phone = IntegerField(_("Phone"), render_kw = FORM_RENDER_KW) + website = StringField(_("Website"), render_kw = FORM_RENDER_KW) + +class LoginForm(Form): + email = EmailField(_("Email"), [ + validators.DataRequired(), + validators.Length(min=6, max=254) + ], + render_kw = FORM_RENDER_KW) + + password = PasswordField(_("Password"), [ + validators.DataRequired(), + validators.Length(min=4, max=127) + ], + render_kw = FORM_RENDER_KW) + +class RegisterForm(Form): + email = EmailField(_("Email"), [ + validators.DataRequired(), + validators.Length(min=6, max=254) + ], + render_kw = FORM_RENDER_KW) + + password = PasswordField(_("Password"), [ + validators.DataRequired(), + validators.Length(min=4, max=127), + validators.EqualTo("confirm_password", message = _("Passwords must match")) + ], + render_kw = FORM_RENDER_KW) + confirm_password = PasswordField(_("Repeat Password"), render_kw = FORM_RENDER_KW) + + firstname = StringField(_("Firstname"), [ + validators.DataRequired(), + validators.Length(min=2, max=30) + ], + render_kw = FORM_RENDER_KW) + + surname = StringField(_("Surname"), [ + validators.DataRequired(), + validators.Length(min=2, max=30) + ], + render_kw = FORM_RENDER_KW) + + accept_tos = BooleanField(_("I accept the TOS"), [validators.DataRequired()]) + +class User(UserMixin): + id = -1 + email = "" + password = "" + firstname = "" + surname = "" + def __init__(self, login): + self.fetch_from_db(login) + + def fetch_from_db(self, login): + conn = glob.get_sql_connection() + cur = conn.cursor() + + cur.execute(""" + SELECT * + FROM Bruker + WHERE Epost = %s + LIMIT 1; + """, (login[0],)) + + user = cur.fetchone() + + cur.close() + + if user is None: + raise Exception(_("Invalid login")) + + if not glob.check_password(login[1], user[2]): + raise Exception(_("Incorrect password")) + + self.id, self.email, self.password, self.firstname, self.surname = user + +def register_account(email, password, firstname, surname): + conn = glob.get_sql_connection() + cur = conn.cursor() + + cur.execute(""" + INSERT INTO + Bruker (Epost, Passord, Fornavn, Etternavn) + VALUES (%s, %s, %s, %s); + """, (email, glob.hash_password(password), firstname, surname)) + + conn.commit() + cur.close() diff --git a/init.sql b/init.sql index e5c9be5..cb887f6 100644 --- a/init.sql +++ b/init.sql @@ -5101,7 +5101,7 @@ CREATE TABLE Husrelasjoner ( BrukerID INT(11), HusID INT(11), - Privleges INT(11), + Privileges INT(11), PRIMARY KEY (BrukerID, HusID), CONSTRAINT FOREIGN KEY (BrukerID) REFERENCES Bruker(BrukerID), CONSTRAINT FOREIGN KEY (HusID) REFERENCES Husstand(HusID) diff --git a/localizer.py b/localizer.py new file mode 100644 index 0000000..d4a71ea --- /dev/null +++ b/localizer.py @@ -0,0 +1,20 @@ +from flask import g, request, session +from flask_babel import Babel + +from objects import glob + +babel = Babel(glob.app) + +LANGUAGES = { + "en": "English", + "no": "Norwegian" +} + +@babel.localeselector +def get_locale(): + # force session lang to be set + session["lang"] = session.get("lang", "en") + + if request.args.get("lang"): + session["lang"] = request.args.get("lang") if request.args.get("lang") in LANGUAGES.keys() else "en" + return session.get("lang", "en") diff --git a/main.py b/main.py index 8c09a6f..d1f7bb2 100644 --- a/main.py +++ b/main.py @@ -3,9 +3,11 @@ from flask import Flask, url_for, request from objects import glob # Global sharing of python objects in a manageable way glob.app = Flask(__name__) +glob.app.secret_key = "E2FGrJXLtOxPh70Q" +import localizer # Initialize localization (Babel) import routes # All flask app routes -import filters # All flask app filters +# import filters # All flask app filters if glob.config["git"]["auto_pull_and_restart"]: # Only used on the VPS (Do not enable in config) @glob.app.route(glob.config["git"]["webhook_endpoint"], methods = ["POST"]) diff --git a/objects/glob.py b/objects/glob.py index 08a05e6..1400ea4 100644 --- a/objects/glob.py +++ b/objects/glob.py @@ -1,11 +1,14 @@ import os import json import shutil +import mysql.connector +import bcrypt # ------------------------------------------------------------------------------ # Global variables that is None by default and gets overwritten in other modules app = None # main.py -> Flask App +sql_conn = None # ------------------------------------------------------------------------------ # Global variables that initializes on first load of module @@ -15,4 +18,19 @@ if not os.path.isfile("config.json"): shutil.copy("default_config.json", "config.json") with open("config.json", "r") as f: - config = json.load(f) \ No newline at end of file + config = json.load(f) + +def make_sql_connection(): + return mysql.connector.connect(**config["mysql"]) + +def get_sql_connection(): + global sql_conn + if sql_conn is None or not sql_conn.is_connected(): + sql_conn = make_sql_connection() + return sql_conn + +def hash_password(password): + return bcrypt.hashpw(password.encode(), bcrypt.gensalt(10, prefix=b"2a")).decode() + +def check_password(p1, p2): + return bcrypt.checkpw(p1.encode(), p2.encode()) diff --git a/requirements.txt b/requirements.txt index 44e7a67..db893c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,6 @@ Flask>=1.0.2 Flask-WTF>=0.14.2 +flask_login>=0.4.1 +Flask-Babel>=0.12.2 +mysql-connector +bcrypt \ No newline at end of file diff --git a/routes.py b/routes.py index 269a169..3ca514e 100644 --- a/routes.py +++ b/routes.py @@ -1,16 +1,180 @@ -from flask import render_template, url_for, request +from flask import render_template, url_for, request, redirect, flash, abort +from wtforms import Form, BooleanField, StringField, PasswordField, validators +import flask_login + +from forms.login import LoginForm, RegisterForm, BillForm, WarrantyForm, ServiceForm, User, register_account + from objects import glob # Global sharing of python objects in a manageable way +from flask_babel import gettext + +login_manager = flask_login.LoginManager() +login_manager.init_app(glob.app) +login_manager.login_view = "login" + +logged_in_users = [] + @glob.app.route("/") @glob.app.route("/home") @glob.app.route("/dashboard") -def home(): +@flask_login.login_required +def dashboard(): return render_template("pages/dashboard.html") -@glob.app.route("/login", methods = ["GET", "POST"]) -def serve_login(): - if request.method == "POST": - return "TODO: Login handle", 501 - return render_template("login.html") +@glob.app.route("/bills", methods = ["GET", "POST"]) +@flask_login.login_required +def bills(): + form = BillForm(request.form) - + conn = glob.make_sql_connection() + cur = conn.cursor() + + if request.method == "POST" and form.validate(): + cur.execute(""" + INSERT + INTO Regninger + VALUES (NULL, %s, %s, %s, %s, %s, 0, 1, %s) + """, (form.payment_to.data, form.description.data, form.kid.data, form.sum.data, form.date_due.data, flask_login.current_user.id)) + + conn.commit() + + return redirect(url_for("bills")) + + cur.execute(""" + SELECT Betaletil, Regningfor, Regningsum, KID, Betalingsfrist, Betalt + FROM Regninger + WHERE BrukerID = %s + """, (flask_login.current_user.id,)) + + data = [] + for row in cur: + data.append(row) + + cur.close() + conn.close() + return render_template("pages/bills.html", data=data, form=form) + +@glob.app.route("/warranties", methods = ["GET", "POST"]) +@flask_login.login_required +def warranties(): + form = WarrantyForm(request.form) + + conn = glob.make_sql_connection() + cur = conn.cursor() + + if request.method == "POST" and form.validate(): + cur.execute(""" + INSERT + INTO Garanti + VALUES (NULL, %s, %s, %s, %s, 1, %s) + """, (form.item.data, form.date_from.data, form.date_to.data, form.sum.data, flask_login.current_user.id)) + + conn.commit() + + return redirect(url_for("warranties")) + + cur.execute(""" + SELECT Vare, Kjøpsdato, Garantitil, Pris + FROM Garanti + WHERE BrukerID = %s + """, (flask_login.current_user.id,)) + + data = [] + for row in cur: + data.append(row) + + cur.close() + conn.close() + return render_template("pages/warranties.html", data=data, form=form) + +@glob.app.route("/receipts", methods = ["GET", "POST"]) +@flask_login.login_required +def receipts(): + return render_template("pages/receipts.html") + +@glob.app.route("/services", methods = ["GET", "POST"]) +@flask_login.login_required +def services(): + form = ServiceForm(request.form) + + conn = glob.make_sql_connection() + cur = conn.cursor() + + if request.method == "POST" and form.validate(): + cur.execute(""" + INSERT + INTO Services + VALUES (NULL, %s, %s, %s, %s, 1, %s, %s) + """, (form.name.data, form.type.data, form.contact.data, form.phone.data, flask_login.current_user.id, form.website.data)) + + conn.commit() + + return redirect(url_for("services")) + + cur.execute(""" + SELECT ServiceName, ServiceType, Kontaktperson, Telefonnummer, Hjemmeside + FROM Services + WHERE BrukerID = %s + """, (flask_login.current_user.id,)) + + data = [] + for row in cur: + data.append(row) + + cur.close() + conn.close() + return render_template("pages/services.html", data=data, form=form) + +@glob.app.route("/login", methods = ["GET", "POST"]) +def login(): + if flask_login.current_user.is_authenticated: + flash(gettext("Already logged in"), "info") + return redirect(url_for("dashboard")) + + form_login = LoginForm(request.form) + form_register = RegisterForm(request.form) + + if request.method == "POST": + if form_register.validate(): + try: + register_account(form_register.email.data, form_register.password.data, form_register.firstname.data, form_register.surname.data) + flash(gettext("User registered"), "success") + except Exception as e: + flash(gettext(str(e)), "danger") + return redirect(url_for("login")) + elif form_login.validate(): + try: + user = User((form_login.email.data, form_login.password.data)) + flask_login.login_user(user) + logged_in_users.append(user) + flash(gettext("Logged in"), "success") + except Exception as e: + flash(gettext(str(e)), "danger") + return redirect(url_for("login")) + return redirect(url_for("dashboard")) # Valid login > Redirect to dashboard as user is logged in + return render_template("login.html", form = { + "login": form_login, + "register": form_register + }) + +@glob.app.route("/logout") +@flask_login.login_required +def logout(): + flask_login.logout_user() + flash(gettext("Logged out"), "success") + return redirect(url_for("login")) + +@glob.app.errorhandler(401) +def unauthorized_handler_err(): + flash(gettext("Login is required"), "danger") + unauthorized_handler() + +@login_manager.user_loader +def load_user(uuid): + uuid = int(uuid) + lst = [x for x in logged_in_users if x.id == uuid] + return lst[0] if len(lst) > 0 else None + +@login_manager.unauthorized_handler +def unauthorized_handler(): + return redirect(url_for("login")) diff --git a/static/css/calendar.css b/static/css/calendar.css index 7a587f8..a41634a 100644 --- a/static/css/calendar.css +++ b/static/css/calendar.css @@ -120,7 +120,7 @@ html[xmlns] .clearfix { .calendar .clndr .clndr-table .header-days { height: 30px; font-size: 10px; - background: #0D70A6; + background: #506EE4; } .calendar .clndr .clndr-table .header-days .header-day { vertical-align: middle; @@ -139,8 +139,7 @@ html[xmlns] .clearfix { vertical-align: top; } .calendar .clndr .clndr-table tr .day { - border-left: 1px solid #000000; - border-top: 1px solid #000000; + border: 1px solid #000000; width: 100%; height: inherit; } @@ -149,23 +148,15 @@ html[xmlns] .clearfix { } .calendar .clndr .clndr-table tr .day.today, .calendar .clndr .clndr-table tr .day.my-today { - background: #9AD6E3; -} -.calendar .clndr .clndr-table tr .day.today:hover, -.calendar .clndr .clndr-table tr .day.my-today:hover { - background: #72c6d8; -} -.calendar .clndr .clndr-table tr .day.today.event, -.calendar .clndr .clndr-table tr .day.my-today.event { - background: #a7dbc1; + box-shadow: inset 0 0 0 2px black; } .calendar .clndr .clndr-table tr .day.event, .calendar .clndr .clndr-table tr .day.my-event { - background: #B4E09F; + background: #a0b3ff; } .calendar .clndr .clndr-table tr .day.event:hover, .calendar .clndr .clndr-table tr .day.my-event:hover { - background: #96d478; + background: #91a3eb; } .calendar .clndr .clndr-table tr .day.inactive, .calendar .clndr .clndr-table tr .day.my-inactive { @@ -228,3 +219,28 @@ html[xmlns] .clearfix { opacity: 0.5; cursor: default; } + +/* +.calendar .clndr .clndr-table tr .day.selected { + outline: 2px dotted black; + outline-offset: -4px; +} +*/ + +.calendar { + height: 578px; + overflow: hidden; +} + +.clndr > .clndr-main > .events { + display: none; + + position: relative; + top: -541px; + height: 541px; + background: #fff; +} + +.clndr > .clndr-main.show-event-menu > .events { + display: block; +} \ No newline at end of file diff --git a/static/css/custom.css b/static/css/custom.css index 923b54b..71b1071 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -4,4 +4,20 @@ html, body { height: 100%; background-color: #eee; font-family: "Poppins", sans-serif; -} \ No newline at end of file +} + +.container.module { + background-color: white; + border-radius: 6px; + padding: 8px; +} + +.content { + display: grid; + grid-template-columns: repeat(4, 25%); + grid-template-rows: repeat(4, 25%); + grid-column-gap: 8px; + grid-row-gap: 8px; + margin-right: 20px; +} + diff --git a/static/img/login-bg.jpg b/static/img/login-bg.jpg new file mode 100644 index 0000000..bccd6a5 Binary files /dev/null and b/static/img/login-bg.jpg differ diff --git a/templates/layout/bootstrap.html b/templates/layout/bootstrap.html index 48653a8..a4027b8 100644 --- a/templates/layout/bootstrap.html +++ b/templates/layout/bootstrap.html @@ -3,7 +3,7 @@
{% include 'layout/includes/boot-head.html' %} {% if title %} -