This commit is contained in:
GPP-USN 2019-09-23 09:20:49 +02:00
commit 0550c9cc19
32 changed files with 1424 additions and 183 deletions

17
BABEL_USAGE.md Normal file
View File

@ -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`

View File

@ -9,7 +9,11 @@ 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
```

3
babel.cfg Normal file
View File

@ -0,0 +1,3 @@
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_

View File

@ -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"
}
}

View File

@ -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)
"""

122
forms/login.py Normal file
View File

@ -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()

View File

@ -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)

20
localizer.py Normal file
View File

@ -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")

View File

@ -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"])

View File

@ -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
@ -16,3 +19,18 @@ if not os.path.isfile("config.json"):
with open("config.json", "r") as f:
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())

View File

@ -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

174
routes.py
View File

@ -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("/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 serve_login():
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":
return "TODO: Login handle", 501
return render_template("login.html")
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"))

View File

@ -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;
}

View File

@ -5,3 +5,19 @@ html, body {
background-color: #eee;
font-family: "Poppins", sans-serif;
}
.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;
}

BIN
static/img/login-bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

View File

@ -3,7 +3,7 @@
<head>
{% include 'layout/includes/boot-head.html' %}
{% if title %}
<title>Husstanden - {{ title }}</title>
<title>Husstanden - {{ _(title) }}</title>
{% else %}
<title>Husstanden</title>
{% endif %}

View File

@ -4,20 +4,42 @@
{% include 'layout/includes/boot-head.html' %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/custom.css') }}">
{% if title %}
<title>Husstanden - {{ title }}</title>
<title>Husstanden - {{ _(title) | title }}</title>
{% else %}
<title>Husstanden</title>
{% endif %}
<style>
#sidenav-container {
flex: 0 0 320px;
display: none;
overflow: hidden;
}
@media screen and (min-width: 768px){
#sidenav-container {
display: block;
}
}
@media screen and (min-width: 1072px){
#sidenav-container {
}
}
</style>
</head>
<body style="height:100%">
<div class="p-2" style="height:100%">
<div class="d-flex" style="height:100%">
<div class="col p-2" style="flex:0 0 320px;">
<div class="col p-2" id="sidenav-container">
{% include 'layout/includes/side_nav.html' %}
</div>
<div class="col p-2">
{% include 'layout/includes/top_nav.html' %}
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
</div>
</div>

View File

@ -7,7 +7,7 @@
}
#sidenav {
background-color: #B507DB;
background-color: #506EE4;
border-radius: 4px;
z-index: 100;
@ -30,7 +30,7 @@
}
#sidenav a {
color: #e9d9fc;
color: #C5CBE2;
line-height: 1;
}
#sidenav a:hover {
@ -104,55 +104,41 @@
<div class="p-2"> <!-- Padding -->
<div class="flex-column">
<div> <!-- Collection -->
<a class="item" onclick="toggleCategory(this)">
<i class="fa fa-chart-bar"></i><span>Dashboard</span>
<i class="fas fa-chevron-right"></i>
<a class="item" href="{{ url_for('dashboard') }}">
<i class="far fa-calendar-alt"></i><span>{{ _("Dashboard") }}</span>
</a>
<div class="downtab hidden">
<div class="flex-column">
<div class="page">
<a href="#dash-test">Test</a>
</div>
<div class="page">
<a href="#dash-demo">Demo</a>
</div>
</div>
</div>
</div>
<div>
<h4>Title card</h4>
<h4>{{ _("Economical") }}</h4>
</div>
<div>
<a class="item" onclick="toggleCategory(this)">
<i class="fa fa-chart-bar"></i><span>Bills</span>
<i class="fas fa-chevron-right"></i>
<a class="item" href="{{ url_for('bills') }}">
<i class="fas fa-money-check-alt"></i><span>{{ _("Bills") }}</span>
</a>
<div class="downtab hidden">
<div class="flex-column">
<div class="page">
<a href="#bills-bills">Bills</a>
</div>
<div class="page">
<a href="#bills-log">Log</a>
</div>
</div>
</div>
</div>
<div>
<a class="item" onclick="toggleCategory(this)">
<i class="fa fa-chart-bar"></i><span>Recieps</span>
<i class="far fa-list-alt"></i><span>{{ _("Receipts") }}</span>
<i class="fas fa-chevron-right"></i>
</a>
<div class="downtab hidden">
<div class="flex-column">
<div class="page">
<a href="#recieps-recieps">Recieps</a>
<a href="{{ url_for('receipts') }}">{{ _("Receipts") }}</a>
</div>
<div class="page">
<a href="#recieps-log">Log</a>
<a href="{{ url_for('warranties') }}">{{ _("Warranties") }}</a>
</div>
</div>
</div>
</div>
<div>
<h4>{{ _("Contacts") }}</h4>
</div>
<div>
<a class="item" href="{{ url_for('services') }}">
<i class="fas fa-tools"></i><span>{{ _("Services") }}</span>
</a>
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
<style>
.rndblock {
border-radius: 100%;
background: #B507DB;
background: #506EE4;
background-position: center;
width: 40px;
height: 40px;
@ -11,13 +11,28 @@
font-size: 24px;
vertical-align: middle;
display: table-cell;
cursor: pointer;
}
.languk {
.lang-icon {
background-position: left;
background-size: contain;
width: 32px;
height: 24px;
color: white;
text-align: center;
font-size: 24px;
vertical-align: middle;
display: table-cell;
}
.lang-en {
background-image: url("{{ url_for('static', filename='const/img/flags/gb.svg') }}");
}
.langno {
.lang-no {
background-image: url("{{ url_for('static', filename='const/img/flags/no.svg') }}");
}
@ -32,10 +47,42 @@
</style>
<nav class="navbar topnav" style="margin-bottom: 10px;">
<h3>{{ title }}</h3>
<h3>{{ _(title) | title }}</h3>
<div>
{% with messages = get_flashed_messages(with_categories = true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} fade in show" role="alert" style="margin-bottom:0px;padding:6px;">
<a href="#" class="close" data-dismiss="alert" aria-label="close" style="margin-left:10px;">&times;</a>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<div class="my-2 my-lg-0 d-flex icon-buttons">
<div class="col">
<div class="rndblock languk"></div>
<div class="rndblock lang-{{ session.lang }}" data-toggle="dropdown" aria-expanded="false"></div>
<ul class="dropdown-menu">
<li>
<div class="row">
<a class="col d-flex m-1" href="?lang=en">
<div class="lang-icon lang-en"></div>
<div class="flex-grow-1"></div>
<span>{{ _("english") | title }}</span>
<div class="flex-grow-1"></div>
</a>
</div>
<div class="row">
<a class="col d-flex m-1" href="?lang=no">
<div class="lang-icon lang-no"></div>
<div class="flex-grow-1"></div>
<span>{{ _("norwegian") | title }}</span>
<div class="flex-grow-1"></div>
</a>
</div>
</li>
</ul>
</div>
<div class="col">
<div class="rndblock">
@ -43,9 +90,18 @@
</div>
</div>
<div class="col">
<div class="rndblock"> <!-- OR INCLUDE BACKGROUND TEXTURE -->
T <!-- FIRST CHAR OF USER -->
<div class="rndblock" data-toggle="dropdown" aria-expanded="false"> <!-- OR INCLUDE BACKGROUND TEXTURE -->
{{ current_user.firstname[0]|upper }} <!-- FIRST CHAR OF USER -->
</div>
<ul class="dropdown-menu">
<li>
<div class="row">
<div class="col">
<a href="{{ url_for('logout') }}">{{ _("Sign out") }}</a>
</div>
</div>
</li>
</ul>
</div>
</div>
</nav>

View File

@ -1,40 +1,254 @@
{% set title = "Login" %}
{% set title = _("Login") %}
{% extends "layout/bootstrap.html" %}
{% block content %}
<div class="container pt-3">
<div class="row alert-section">
<div class="col-md-8 mx-auto">
<div class="card rounded-1">
<div class="card-header">
<h3 class="mb-0">Login Methods</h3>
</div>
<div class="card-body row">
<div class="col-md-6">
<h5>Electronic ID</h5>
<p class="lead">
Secure login using Electronic ID allows Husstanden to show you banking details, bills and receipts.<br>
<a target="_blank" href="https://eid.difi.no/en/id-porten/how-obtain-electronic-id">How to obtain an Electronic ID</a>
</p>
<style>
html, body {
height: 100%;
scroll-behavior: smooth;
}
.main-head{
height: 150px;
background: #FFF;
}
.sidenav {
height: 100%;
background-color: #000;
overflow-x: hidden;
padding-top: 20px;
display: grid;
grid-template-rows: 25% 50% 25%;
height: 100%;
overflow: hidden;
}
.sidenav > div:nth-child(1n) {
grid-row-start: 1;
}
.sidenav > div:nth-child(2n) {
grid-row-start: 2;
align-self: center;
}
.sidenav > div:nth-child(3n) {
grid-row-start: 3;
align-self: end;
}
#main {
padding: 0px 10px;
display: grid;
grid-template-rows: 25% auto;
height: 100%;
}
#main > .col:nth-child(1n) {
grid-row-start: 1;
}
#main > .col:nth-child(2n) {
grid-row-start: 2;
}
#focus-login {
text-decoration: none;
color: #fff;
}
@media screen and (max-height: 450px) {
.sidenav {padding-top: 15px;}
}
@media screen and (max-width: 450px) {
.login-form{
margin-top: 10%;
}
.register-form{
margin-top: 10%;
}
}
@media screen and (min-width: 768px){
#main{
margin-left: 40%;
}
.sidenav {
width: 40%;
position: fixed;
z-index: 1;
top: 0;
left: 0;
}
#focus-login {
display: none;
}
}
.login-main-text{
padding: 60px;
color: #fff;
}
.login-main-text h2{
font-weight: 300;
}
.btn-black{
background-color: #000 !important;
color: #fff;
}
.hidable[aria-expanded="true"] {
display: none;
}
.toggle-form > form:nth-child(2n) {
display: none;
}
.toggle-form.toggled > form:nth-child(1n) {
display: none;
}
.toggle-form.toggled > form:nth-child(2n) {
display: block;
}
.lang-icon {
background-position: left;
background-size: contain;
width: 32px;
height: 24px;
color: white;
text-align: center;
font-size: 24px;
vertical-align: middle;
display: table-cell;
}
.lang-en {
background-image: url("{{ url_for('static', filename='const/img/flags/gb.svg') }}");
}
.lang-no {
background-image: url("{{ url_for('static', filename='const/img/flags/no.svg') }}");
}
.dropdown-menu {
background-color: #6c757d;
}
.dropdown-menu a {
color: white;
}
</style>
<script>
function toggleform(caller) {
let selectedDOM = caller;
do {
if (selectedDOM.classList.contains("toggle-form")){
let classes = selectedDOM.classList;
classes[classes.contains("toggled") ? "remove" : "add"]("toggled");
return;
}
selectedDOM = selectedDOM.parentElement;
} while (selectedDOM != null);
throw Error("Missing toggle-form class for self/parent(s)");
}
</script>
<div class="sidenav">
<div class="login-main-text">
<h2>Husstanden<br>{{ _("Login Page") }}</h2>
<p>{{ _("Login is required to use this service.") }}</p>
</div>
<div class="text-center mx-auto">
<a href="#main" class="display-1" id="focus-login">Login</a>
</div>
<div class="btn-group dropup m-2">
<div>
<div class="btn btn-secondary col d-flex m-1" data-toggle="dropdown" aria-expanded="false">
<div class="lang-icon lang-{{ session.lang }}"></div>
<div class="flex-grow-1"></div>
<span>{{ _("Language") }}</span>
<div class="flex-grow-1"></div>
</div>
<ul class="dropdown-menu">
<li>
<div class="row">
<a class="col d-flex m-1" href="?lang=en">
<div class="lang-icon lang-en"></div>
<div class="flex-grow-1"></div>
<span>{{ _("english") | title }}</span>
<div class="flex-grow-1"></div>
</a>
</div>
<div class="col-md-6">
<h5>House Account</h5>
<p class="lead">
Accounts with less privileges and fast login to view non-sensitive information.<br>
Multiple house accounts can be created by logging in with house holder's verified <i>Electronic ID</i>.
</p>
<div class="row">
<a class="col d-flex m-1" href="?lang=no">
<div class="lang-icon lang-no"></div>
<div class="flex-grow-1"></div>
<span>{{ _("norwegian") | title }}</span>
<div class="flex-grow-1"></div>
</a>
</div>
</div>
<hr>
<div class="card-body row pt-2">
<div class="col-md-6 pb-2">
<a class="btn btn-primary btn-lg btn-block" href="#TODO_LOGIN_EID" onclick="alertAbove(this, 'warning', 'This should redirect the user to Electronic ID page (but due to phishing, we can not demo this)')">Electronic ID</a>
</div>
<div class="col-md-6">
<a class="btn btn-primary btn-lg btn-block" id="test" href="#TODO_LOGIN_HA" onclick="alertAbove(this, 'info', 'Unimplemented - Awaiting database')">House Account</a>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<div id="main">
<div class="col pt-4">
{% with messages = get_flashed_messages(with_categories = true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} fade in show" role="alert" style="margin-bottom:0px;padding:6px;">
<a href="#" class="close" data-dismiss="alert" aria-label="close" style="margin-left:10px;">&times;</a>
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
<div class="col col-md-6 col-sm-12">
<div class="login-form">
{% macro render_field(field) %}
<div class="form-group">
<label for="{{ field.label.field_id }}">{{ _(field.label.text) }}</label>
{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}
<div class="toggle-form false">
<form method="post">
{{ render_field(form.login.email) }}
{{ render_field(form.login.password) }}
<button class="btn btn-black">{{ _("Login") }}</button>
<span class="btn btn-secondary" onclick="toggleform(this)">{{ _("Register") }}</span>
</form>
<form method="post">
{{ render_field(form.register.email) }}
{{ render_field(form.register.password) }}
{{ render_field(form.register.confirm_password) }}
{{ render_field(form.register.firstname) }}
{{ render_field(form.register.surname) }}
{{ render_field(form.register.accept_tos) }}
<span class="btn btn-secondary" onclick="toggleform(this)">{{ _("Login") }}</span>
<button class="btn btn-black">{{ _("Register") }}</button>
</form>
</div>
</div>
</div>

View File

@ -1,21 +1,89 @@
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/calendar.css') }}">
<div class="calendar"></div>
<div class="calendar">
<script type="text/template" id="tmplt-clndr">
<div class='clndr-controls'>
<div class='clndr-control-button'>
<span class='clndr-previous-button'>&lsaquo; previous</span>
</div>
<div class='month'><%= month %> <%= year %></div>
<div class='clndr-control-button rightalign'>
<span class='clndr-next-button'>next &rsaquo;</span>
</div>
</div>
<div class="clndr-main">
<table class='clndr-table' border='0' cellspacing='0' cellpadding='0'>
<thead>
<tr class='header-days'>
<% for(var i = 0; i < daysOfTheWeek.length; i++) { %>
<td class='header-day'><%= daysOfTheWeek[i] %></td>
<% } %>
</tr>
</thead>
<tbody>
<% for(var i = 0; i < numberOfRows; i++){ %>
<tr>
<% for(var j = 0; j < 7; j++){ %>
<% var d = j + i * 7; %>
<td class='<%= days[d].classes %>'>
<div class='day-contents'><%= days[d].day %></div>
</td>
<% } %>
</tr>
<% } %>
</tbody>
</table>
</div>
</script>
<script type="text/template" id="tmplt-clndr-events">
<div class='clndr-controls'>
<div class='clndr-control-button'>
<span class='clndr-previous-button'>&lsaquo; previous</span>
</div>
<div class='month'><%= extras.selected.format("LL") %></div>
<div class='clndr-control-button rightalign'>
<span class='clndr-next-button'>next &rsaquo;</span>
</div>
</div>
<div class="clndr-main">
<table class='clndr-table' border='0' cellspacing='0' cellpadding='0'>
<thead>
<tr class='header-days'>
<% for(var i = 0; i < daysOfTheWeek.length; i++) { %>
<td class='header-day'><%= daysOfTheWeek[i] %></td>
<% } %>
</tr>
</thead>
<tbody>
<% for(var i = 0; i < numberOfRows; i++){ %>
<tr>
<% for(var j = 0; j < 7; j++){ %>
<% var d = j + i * 7; %>
<td class='<%= days[d].classes %>'>
<div class='day-contents'><%= days[d].day %></div>
</td>
<% } %>
</tr>
<% } %>
</tbody>
</table>
</div>
</script>
</div>
<script src="{{ url_for('static', filename='js/moment.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/underscore.min.js') }}"></script>
<script src="{{ url_for('static', filename='modules/clndr.min.js') }}"></script>
<script>
var calendars = {};
var tmplts = {};
var a;
$(document).ready( function() {
// Assuming you've got the appropriate language files,
// clndr will respect whatever moment's language is set to.
// moment.locale('ru');
tmplts.clndr = $('#tmplt-clndr').html();
tmplts.clndrEvents = $('#tmplt-clndr-events').html();
// Here's some magic to make sure the dates are happening this month.
var thisMonth = moment().format('YYYY-MM');
// Events to load into calendar
var eventArray = [ // TODO: Get events from database
{
title: 'Multi-Day Event',
@ -28,19 +96,28 @@ $(document).ready( function() {
}, {
date: thisMonth + '-27',
title: 'Single Day Event'
}
}, {
date: thisMonth + '-6',
title: 'Test'
}
];
// The order of the click handlers is predictable. Direct click action
// callbacks come first: click, nextMonth, previousMonth, nextYear,
// previousYear, nextInterval, previousInterval, or today. Then
// onMonthChange (if the month changed), inIntervalChange if the interval
// has changed, and finally onYearChange (if the year changed).
calendars.clndr = $('.calendar').clndr({
events: eventArray,
clickEvents: {
click: function (target) {
console.log('Cal-1 clicked: ', target);
a = target;
console.log(target.date.day());
this.options.extras.selected = target.date;
this.compiledClndrTemplate = _.template(tmplts.clndrEvents);
this.render();
/*
var clndrContainer = $('.calendar').find('.clndr-main');
clndrContainer.toggleClass('show-event-menu', true);
$('.calendar').find('.x-button').click( function() {
clndrContainer.toggleClass('show-event-menu', false);
});
*/
},
today: function () {
console.log('Cal-1 today');
@ -53,24 +130,6 @@ $(document).ready( function() {
},
onMonthChange: function () {
console.log('Cal-1 month changed');
},
nextYear: function () {
console.log('Cal-1 next year');
},
previousYear: function () {
console.log('Cal-1 previous year');
},
onYearChange: function () {
console.log('Cal-1 year changed');
},
nextInterval: function () {
console.log('Cal-1 next interval');
},
previousInterval: function () {
console.log('Cal-1 previous interval');
},
onIntervalChange: function () {
console.log('Cal-1 interval changed');
}
},
multiDayEvents: {
@ -79,17 +138,19 @@ $(document).ready( function() {
startDate: 'startDate'
},
showAdjacentMonths: true,
adjacentDaysChangeMonth: false
adjacentDaysChangeMonth: false,
weekOffset: 1,
forceSixRows: true,
template: tmplts.clndr,
extras: {
selected: null
}
});
// Bind all clndrs to the left and right arrow keys
$(document).keydown( function(e) {
// Left arrow
if (e.keyCode == 37) {
calendars.clndr.back();
}
// Right arrow
if (e.keyCode == 39) {
calendars.clndr.forward();
}

View File

@ -1,11 +0,0 @@
{% set title = "bill:)" %}
{% extends "layout/bootstrap.html" %}
{% block content %}
<p>lotsa bills</p>
<p>many bills</p>
<i class="fas fa-user"></i> <!-- uses solid style -->
<i class="far fa-user"></i> <!-- uses regular style -->
<i class="fab fa-github-square"></i> <!-- uses brands style -->
{% endblock %}

View File

@ -0,0 +1,84 @@
{% set title = _("Bills") %}
{% extends "layout/dash.html" %}
{% block content %}
<style id="grid">
.container.module:nth-child(1n) {
grid-area: 1 / 1 / 5 / 5;
}
</style>
<div class="container module">
<button type="button" class="btn btn-primary" style="margin:10px;" data-toggle="modal" data-target="#myModal">{{ _("Add") }}</button>
<div class="modal fade" id="myModal" role="dialog">
<div class="modal-dialog">
<!-- Modal content-->
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ _("Add bill") }}</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
{% macro render_field(field) %}
<div class="form-group">
<label for="{{ field.label.field_id }}">{{ _(field.label.text) }}</label>
{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}
<form method=post>
<dl>
{{ render_field(form.payment_to) }}
{{ render_field(form.description) }}
{{ render_field(form.sum) }}
{{ render_field(form.kid) }}
{{ render_field(form.date_due) }}
</dl>
<input type=submit value="{{ _('Add') }}">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ _("Close") }}</button>
</div>
</div>
</div>
</div>
<table class="table">
<thead class="thead-light">
<tr>
<th scope="col">{{ _("Payment to") }}</th>
<th scope="col">{{ _("Description") }}</th>
<th scope="col" style="width: 120px">{{ _("Sum") }}</th>
<th scope="col" style="width: 220px">{{ _("KID") }}</th>
<th scope="col" style="width: 120px">{{ _("Date due") }}</th>
<th scope="col" style="width: 150px">{{ _("Payment status") }}</th>
</tr>
</thead>
<tbody>
{% for row in data %}
<!--<tr class="table-{{ 'success' if row[5] else 'danger' }}">-->
<tr>
<th>{{ row[0] }}</th>
<td>{{ row[1] }}</td>
<td>{{ row[2] }}</td>
<td>{{ row[3] }}</td>
<td>{{ row[4] }}</td>
<td style="background-color:{{ '#4F4' if row[5] else '#F44' }};"></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,9 +1,31 @@
{% set title = "Dashboard" %}
{% set title = _("Dashboard") %}
{% extends "layout/dash.html" %}
{% block content %}
<div class="container module">
<h4>Reminders</h4>
</div>
<div class="container module">
<h4>Calendar</h4>
{% include "modules/calendar.html" %}
</div>
<style id="grid">
.container.module:nth-child(1n) {
grid-area: 1 / 1 / 5 / 2;
}
.container.module:nth-child(2n) {
grid-area: 1 / 2 / 5 / 5;
}
</style>
<script>
function onDaySelect() {
}
</script>
{% endblock %}

View File

@ -1,10 +0,0 @@
{% set title = "Kvitteringer" %}
{% extends "layout/bootstrap.html" %}
{% block content %}
<p>Tester siden!</p>
<p>KVITTERINGER FOR FAEN!</p>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% set title = _("Receipts") %}
{% extends "layout/dash.html" %}
{% block content %}
<style id="grid">
.container.module:nth-child(1n) {
grid-area: 1 / 1 / 5 / 5;
}
</style>
<div class="container module">
<a>Unfinished</a>
</div>
{% endblock %}

View File

@ -1,8 +0,0 @@
{% set title = "Regninger" %}
{% extends "layout/bootstrap.html" %}
{% block content %}
<p>Regninger :D</p>
<p>test</p>
{% endblock %}

View File

@ -0,0 +1,82 @@
{% set title = _("Services") %}
{% extends "layout/dash.html" %}
{% block content %}
<style id="grid">
.container.module:nth-child(1n) {
grid-area: 1 / 1 / 5 / 5;
}
</style>
<div class="container module">
<button type="button" class="btn btn-primary" style="margin:10px;" data-toggle="modal" data-target="#myModal">{{ _("Add") }}</button>
<div class="modal fade" id="myModal" role="dialog">
<div class="modal-dialog">
<!-- Modal content-->
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ _("Add service") }}</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
{% macro render_field(field) %}
<div class="form-group">
<label for="{{ field.label.field_id }}">{{ _(field.label.text) }}</label>
{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}
<form method=post>
<dl>
{{ render_field(form.name) }}
{{ render_field(form.type) }}
{{ render_field(form.contact) }}
{{ render_field(form.phone) }}
{{ render_field(form.website) }}
</dl>
<input type=submit value="{{ _('Add') }}">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ _("Close") }}</button>
</div>
</div>
</div>
</div>
<table class="table">
<thead class="thead-light">
<tr>
<th scope="col">{{ _("Name") }}</th>
<th scope="col">{{ _("Type") }}</th>
<th scope="col">{{ _("Contact") }}</th>
<th scope="col">{{ _("Phone") }}</th>
<th scope="col">{{ _("Website") }}</th>
</tr>
</thead>
<tbody>
{% for row in data %}
<!--<tr class="table-{{ 'success' if row[5] else 'danger' }}">-->
<tr>
<th>{{ row[0] }}</th>
<td>{{ row[1] }}</td>
<td>{{ row[2] }}</td>
<td>{{ row[3] }}</td>
<td>{{ row[4] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -1,8 +0,0 @@
{% set title = "testing" %}
{% extends "layout/bootstrap.html" %}
{% block content %}
<p>testing</p>
<p>tester dette</p>
{% endblock %}

View File

@ -0,0 +1,79 @@
{% set title = _("Warranties") %}
{% extends "layout/dash.html" %}
{% block content %}
<style id="grid">
.container.module:nth-child(1n) {
grid-area: 1 / 1 / 5 / 5;
}
</style>
<div class="container module">
<button type="button" class="btn btn-primary" style="margin:10px;" data-toggle="modal" data-target="#myModal">{{ _("Add") }}</button>
<div class="modal fade" id="myModal" role="dialog">
<div class="modal-dialog">
<!-- Modal content-->
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ _("Add warrenty") }}</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<div class="modal-body">
{% macro render_field(field) %}
<div class="form-group">
<label for="{{ field.label.field_id }}">{{ _(field.label.text) }}</label>
{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}
<form method=post>
<dl>
{{ render_field(form.item) }}
{{ render_field(form.date_from) }}
{{ render_field(form.date_to) }}
{{ render_field(form.sum) }}
</dl>
<input type=submit value="{{ _('Add') }}">
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ _("Close") }}</button>
</div>
</div>
</div>
</div>
<table class="table">
<thead class="thead-light">
<tr>
<th scope="col">{{ _("Item") }}</th>
<th scope="col" style="width: 140px">{{ _("Date of purchase") }}</th>
<th scope="col" style="width: 140px">{{ _("Warranty duration") }}</th>
<th scope="col" style="width: 140px">{{ _("Sum") }}</th>
</tr>
</thead>
<tbody>
{% for row in data %}
<!--<tr class="table-{{ 'success' if row[5] else 'danger' }}">-->
<tr>
<th>{{ row[0] }}</th>
<td>{{ row[1] }}</td>
<td>{{ row[2] }}</td>
<td>{{ row[3] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

55
templates/register.html Normal file
View File

@ -0,0 +1,55 @@
{% set title = "Register" %}
{% extends "layout/bootstrap.html" %}
{% block content %}
{% macro render_field(field) %}
<dt>{{ field.label }}
<dd>{{ field(**kwargs)|safe }}
{% if field.errors %}
<ul class=errors>
{% for error in field.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</dd>
{% endmacro %}
<script src="{{ url_for('static', filename='js/alerts.js') }}"></script>
<div class="container pt-3">
<div class="row alert-section">
<div class="col-md-8 mx-auto">
<div class="card rounded-1">
<div class="card-header">
<h3 class="mb-0">Register</h3>
</div>
<div class="card-body row">
<div class="col-md-6">
<form method=post>
<dl>
{{ render_field(form.email) }}
{{ render_field(form.password) }}
{{ render_field(form.confirm_password) }}
{{ render_field(form.firstname) }}
{{ render_field(form.surname) }}
{{ render_field(form.accept_tos) }}
</dl>
<input type=submit value="Register">
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
{% with messages = get_flashed_messages() %}
{% if messages %}
let alertArea = document.getElementsByClassName("alert-section")[0];
{% for message in messages %}
alertAbove(alertArea, "info", "{{ message }}");
{% endfor %}
{% endif %}
{% endwith %}
</script>
{% endblock %}

View File

@ -0,0 +1,215 @@
# Norwegian text and messages
#
msgid ""
msgstr ""
"Project-Id-Version: 0.4\n"
"Report-Msgid-Bugs-To: noreply@osufx.com\n"
"POT-Creation-Date: 2019-05-28 19:59+0200\n"
"PO-Revision-Date: 2019-05-12 09:47+1000\n"
"Last-Translator: Emily Steinsvik <emily@osufx.com>\n"
"Language: no\n"
"Language-Team: no <emily@osufx.com>\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.7.0\n"
#: routes.py:131
msgid "Already logged in"
msgstr "Allerede logget inn"
#: routes.py:141
msgid "User registered"
msgstr "Bruker registrert"
#: routes.py:150
msgid "Logged in"
msgstr "Logget inn"
#: routes.py:164
msgid "Logged out"
msgstr "Logget ut"
#: routes.py:169
msgid "Login is required"
msgstr "Innlogging kreves"
#: forms/login.py:14 templates/pages/bills.html:59
msgid "Payment to"
msgstr "Betaling til"
#: forms/login.py:15 templates/pages/bills.html:60
msgid "Description"
msgstr "Tekst"
#: forms/login.py:20 forms/login.py:28 templates/pages/bills.html:61
#: templates/pages/warranties.html:61
msgid "Sum"
msgstr "Sum"
#: forms/login.py:21 templates/pages/bills.html:62
msgid "KID"
msgstr "KID"
#: forms/login.py:22 templates/pages/bills.html:63
msgid "Date due"
msgstr "Forfallsdato"
#: forms/login.py:25 templates/pages/warranties.html:58
msgid "Item"
msgstr "Vare"
#: forms/login.py:26 templates/pages/warranties.html:59
msgid "Date of purchase"
msgstr "Kjøpsdato"
#: forms/login.py:27 templates/pages/warranties.html:60
msgid "Warranty duration"
msgstr "Garanti til"
#: forms/login.py:31 templates/pages/services.html:59
msgid "Name"
msgstr "Navn"
#: forms/login.py:32 templates/pages/services.html:60
msgid "Type"
msgstr "Type"
#: forms/login.py:33 templates/pages/services.html:61
msgid "Contact"
msgstr "Kontaktperson"
#: forms/login.py:34 templates/pages/services.html:62
msgid "Phone"
msgstr "Telefon"
#: forms/login.py:35 templates/pages/services.html:63
msgid "Website"
msgstr "Nettside"
#: forms/login.py:38 forms/login.py:51
msgid "Email"
msgstr "Epost"
#: forms/login.py:44 forms/login.py:57
msgid "Password"
msgstr "Passord"
#: forms/login.py:60
msgid "Passwords must match"
msgstr "Passordene må være like"
#: forms/login.py:63
msgid "Repeat Password"
msgstr "Gjenta passord"
#: forms/login.py:65
msgid "Firstname"
msgstr "Fornavn"
#: forms/login.py:71
msgid "Surname"
msgstr "Etternavn"
#: forms/login.py:77
msgid "I accept the TOS"
msgstr "Jeg aksepterer TOS"
#: forms/login.py:104
msgid "Invalid login"
msgstr "Ugyldig innlogging"
#: forms/login.py:107
msgid "Incorrect password"
msgstr "Feil passord"
#: templates/login.html:1 templates/login.html:239 templates/login.html:249
msgid "Login"
msgstr "Login"
#: templates/login.html:170
msgid "Login Page"
msgstr "Innlogging"
#: templates/login.html:171
msgid "Login is required to use this service."
msgstr "Innlogging kreves for å bruke denne tjenesten."
#: templates/login.html:181
msgid "Language"
msgstr "Språk"
#: templates/layout/includes/top_nav.html:72 templates/login.html:190
msgid "english"
msgstr "engelsk"
#: templates/layout/includes/top_nav.html:80 templates/login.html:198
msgid "norwegian"
msgstr "norsk"
#: templates/login.html:240 templates/login.html:250
msgid "Register"
msgstr "Registrer"
#: templates/layout/includes/side_nav.html:108 templates/pages/dashboard.html:1
msgid "Dashboard"
msgstr "Dashbord"
#: templates/layout/includes/side_nav.html:112
msgid "Economical"
msgstr "Økonomisk"
#: templates/layout/includes/side_nav.html:116 templates/pages/bills.html:1
msgid "Bills"
msgstr "Regninger"
#: templates/layout/includes/side_nav.html:121
#: templates/layout/includes/side_nav.html:127 templates/pages/receipts.html:1
msgid "Receipts"
msgstr "Kvitteringer"
#: templates/layout/includes/side_nav.html:130
#: templates/pages/warranties.html:1
msgid "Warranties"
msgstr "Garantier"
#: templates/layout/includes/side_nav.html:136
msgid "Contacts"
msgstr "Kontakter"
#: templates/layout/includes/side_nav.html:140 templates/pages/services.html:1
msgid "Services"
msgstr "Service"
#: templates/layout/includes/top_nav.html:100
msgid "Sign out"
msgstr "Logg ut"
#: templates/pages/bills.html:15 templates/pages/bills.html:46
#: templates/pages/services.html:15 templates/pages/services.html:46
#: templates/pages/warranties.html:15 templates/pages/warranties.html:45
msgid "Add"
msgstr "Legg til"
#: templates/pages/bills.html:21
msgid "Add bill"
msgstr "Legg til regning"
#: templates/pages/bills.html:50 templates/pages/services.html:50
#: templates/pages/warranties.html:49
msgid "Close"
msgstr "Lukk"
#: templates/pages/bills.html:64
msgid "Payment status"
msgstr "Betalingsstatus"
#: templates/pages/services.html:21
msgid "Add service"
msgstr "Legg til service"
#: templates/pages/warranties.html:21
msgid "Add warrenty"
msgstr "Legg til garanti"