90 Commits

Author SHA1 Message Date
af6f271b29 Merge branch 'development' into 'master'
Development

See merge request !30
2016-11-15 21:42:08 +01:00
c9b1a1b095 Merge branch 'special_costs' into 'development'
Special costs

See merge request !29
2016-11-15 21:33:10 +01:00
2e4c815a10 Add of db update script 2016-11-15 21:31:37 +01:00
5859763a14 Fixed deprecation warning for Form 2016-11-06 18:53:13 +01:00
6e665b9f2f finished handling of services 2016-11-06 13:22:54 +01:00
60a1b307e4 changes to beautify the generated markdown 2016-11-06 13:14:28 +01:00
3f458937d9 adds markdown filter 2016-11-06 13:14:03 +01:00
bceb9181ee sorting for events 2016-11-01 19:48:24 +01:00
086c643365 fixed shadowing warning 2016-11-01 19:48:08 +01:00
d8c6a0a59b splitted routes into package 2016-11-01 18:01:52 +01:00
43d9fc412e moved forms to extra package 2016-11-01 16:36:28 +01:00
71412bf487 integrate services into rollerverbrauch 2016-11-01 11:19:26 +01:00
dd11419305 add the crud forms for services 2016-11-01 11:18:40 +01:00
d132dc72de Add Entitiy for the Service Object 2016-11-01 11:17:54 +01:00
43fa41aace Add input type TextAreaField
adds a possibility to add longer texts in the style of
rollerverbrauch
2016-11-01 11:16:13 +01:00
b8a6f69090 add gitattributes to handle line ending settings and expose ports on docker compose 2016-08-28 15:45:08 +02:00
bb7c2780e8 Merge branch '11_Redirect_to_edit_vehicle_page_if_no_consumable_is_configured' into 'development'
11 redirect to edit vehicle page if no consumable is configured



See merge request !28
2016-08-28 14:01:37 +02:00
088d1f24a0 add redirect if vehicle is not configured and flash messages 2016-08-16 13:21:54 +02:00
87b3b02f3e add sqlite files to gitignore 2016-08-07 13:19:26 +02:00
c8a9404bf6 fixed use of os.environ 2016-08-07 13:18:39 +02:00
4acf36112c add ids to pitstop elements 2016-08-07 13:18:04 +02:00
4b73699750 Added ids to important elements 2016-08-06 10:59:20 +02:00
d393f19b47 Fixed wrong text 2016-08-06 10:59:03 +02:00
44b92e58b5 use config variable to identify admin account while it is created 2016-08-03 08:13:39 +02:00
1e7db29de9 add config variable to identify admin account while it is created 2016-08-03 08:12:06 +02:00
938a7fc140 Removed dependency to mysql for testing 2016-08-03 08:09:58 +02:00
49a45ba8a4 Merge branch 'development' into 'selenium_tests'
Sync Development to selenium tests



See merge request !27
2016-07-30 09:23:14 +02:00
adf51c85f2 Merge branch 'development' into 'master'
Development

Fixes:
 * wrong date preselected when creation a pitstop
 * creation of new vehicle

See merge request !26
2016-07-29 21:50:52 +02:00
48d7c108f1 Merge branch '9-creation-of-new-vehicle-fails' into 'development'
Resolve "Creation of new vehicle fails"

Closes #9

See merge request !25
2016-07-29 21:12:30 +02:00
7129e676e2 Fixed issue 9 2016-07-29 21:10:58 +02:00
aaa94f08fa Merge branch 'issue_8_wrong_date_on_creating_new_pitstop' into 'development'
fix issue 8



See merge request !24
2016-07-29 21:02:43 +02:00
92fb25c1c1 fix issue 8 2016-07-25 18:54:27 +02:00
5fa710149d Merge branch 'development' into 'master'
Merge preparation for Esther



See merge request !23
2016-07-24 11:26:34 +02:00
c815c2b09a Merge branch 'style_consumable_selection' into 'development'
Style consumable selection



See merge request !22
2016-07-23 13:14:05 +02:00
529f9a6c64 Fixed problem with preselected values 2016-07-23 13:09:38 +02:00
bebccf0a36 Merge branch 'issue_7_really_delete_pitstop_page_does_not_show_all_information' into 'development'
Fix issue with missing information on pitstop delete page



See merge request !21
2016-07-21 19:14:45 +02:00
89ebab29c4 Merge branch 'issue_6_pitstop_is_not_editable' into 'development'
Fixed the edit function



See merge request !20
2016-07-21 19:14:21 +02:00
a4ecae4700 Fix issue with missing information on pitstop delete page 2016-07-21 19:11:32 +02:00
88f88591a9 Fixed the edit function 2016-07-21 19:03:04 +02:00
473c2a4f5b Slight style improvement 2016-07-18 21:30:06 +02:00
bcc0541be3 Merge branch 'development' into selenium_tests 2016-07-17 10:09:01 +02:00
fd685c316d Update README.md 2016-07-17 10:06:19 +02:00
112ed38dbd fixed SQL Error with MySQL 5.7.12 2016-07-17 10:03:56 +02:00
2f737c1715 fixed DB URL 2016-07-17 10:02:41 +02:00
5a447763d0 Merge branch 'development' into 'master'
Merge branch 'development' into 'master'

Merge preparation for  Danielle

This adds the following features:
 * Add accounting for additional consumable materials (#5)
 * Add cost field to pit stop (#4)
 * Make last pit stop editable / deletable (#3)

See merge request !19
2016-07-17 09:40:54 +02:00
cfb9dfabc0 Merge branch 'issue_5_more_consumable_material' into development
Conflicts:
	app/__init__.py
2016-07-17 09:22:38 +02:00
c0f6ef7b4f Merge branch 'pycharm' into 'development'
Fixed project structure for pycharm



See merge request !16
2016-07-17 08:10:04 +02:00
fdb8993ac3 Merge branch 'issue_5_more_consumable_material' into pycharm
Conflicts:
	app/__init__.py
	app/rollerverbrauch/templates/createPitStopForm.html
	app/rollerverbrauch/templates/newPitStopForm.html
	app/rollerverbrauch/templates/selectVehice.html
	app/rollerverbrauch/templates/selectVehicle.html
	app/templates/newPitStopForm.html
	app/templates/selectVehice.html
2016-07-16 19:29:25 +02:00
1f5cb6b1fa Fixed issue with status on non-logged in index page 2016-07-16 13:29:32 +02:00
2a524ffdbf Merge branch 'issue_5_more_consumable_material' into 'development'
Issue 5 more consumable material



See merge request !17
2016-07-16 12:12:43 +02:00
1516ac2c51 db update script and some fixes 2016-07-16 12:07:01 +02:00
aa9fda93c0 statistics 3rd 2016-07-11 22:15:23 +02:00
ca5bd9bb02 statistics 2nd step 2016-07-11 06:59:34 +02:00
4ea42a7efd first step for statistics 2016-07-10 13:40:49 +02:00
43e3f9c98b . 2016-07-04 20:19:59 +02:00
738bcb9f89 Fixed project structure for pycharm 2016-07-03 19:29:30 +02:00
af5d4ae6b3 cann add pitstops for selected vehicles / consumables 2016-06-29 22:50:01 +02:00
907d0435d1 consumables for vehicles can be edited 2016-06-28 23:33:24 +02:00
fe4236eead renamed template for create a pitstop 2016-06-28 21:35:04 +02:00
a0be122186 reworked the crud funtions for vehicles 2016-06-27 22:09:42 +02:00
fa4126be66 adds update & delete for consumables
adds functionality to
* update
* delete
for consumables
2016-06-27 21:35:00 +02:00
60f2e9f4e4 adds form to create a consumable 2016-06-27 20:08:49 +02:00
5e24b9779e makes the name of a consumable unique
makes the column unique and extends the create form to respect the
uniqueness.
2016-06-27 19:48:04 +02:00
90639a757d Merge branch 'development' into issue_5_more_consumable_material 2016-06-26 08:27:14 +02:00
820063837d Merge branch 'issue_4_add_cost_field' into development 2016-06-26 08:26:33 +02:00
f2623cf6e9 bugfix for creating first pitstop of a vehicle 2016-06-26 08:25:53 +02:00
1072142a1b adds consumables to database
this adds a new consumables entity to the database and
the possibility in the admin page to add these.
2016-06-26 08:19:28 +02:00
56757f29a1 Merge branch 'issue_3_last_stop_editable' into 'development'
Issue 3 last stop editable



See merge request !15
2016-06-24 07:17:00 +02:00
9d41c39ae7 adds edit feature for last pitstop
Adds the possibility to edit the last pitstop in a dedicated
form.
2016-06-24 07:15:24 +02:00
6f7716abc6 adds delete feature for last pitstop
Adds the possibility to delete the latest pitstop after a
confirmation page.
2016-06-22 22:48:27 +02:00
6d26ceeae8 Merge branch 'issue_4_add_cost_field' into 'development'
adds tracking of costs

This commit adds a new field to each pitstop for tracking of
costs.
This includes:
* a new DB column
* a new field in the create pitstop form
* a new column in the pitstop overview
* new stats values related to costs
* 2 new graphs

See merge request !14
2016-06-22 21:01:46 +02:00
5b69a82e05 adds tracking of costs
This commit adds a new field to each pitstop for tracking of
costs.
This includes:
* a new DB column
* a new field in the create pitstop form
* a new column in the pitstop overview
* new stats values related to costs
* 2 new graphs
2016-06-21 21:06:41 +02:00
f82cf425fd Merge branch 'development' into 'master'
Merge preparation for Cathrine



See merge request !13
2016-05-28 20:47:26 +02:00
936fbf30f6 worked on tests 2016-05-28 18:59:23 +02:00
7696fb870c Merge branch 'docker_compose' into 'development'
Docker compose



See merge request !12
2016-05-28 17:13:22 +02:00
3f8f30f5d1 improved tests
test code waits for selenium to be available
screenshots get named more precisely
2016-05-27 11:33:15 +02:00
cc414092a3 Adds first steps towards auto tests 2016-05-27 03:24:44 +02:00
63ff5845e2 added demo config 2016-05-26 13:54:51 +02:00
10494a03a9 first version for docker-compose 2016-05-26 12:08:24 +02:00
2176579baa Merge branch 'implement_delete_account' into 'development'
Implement delete account



See merge request !11
2016-05-25 09:41:15 +02:00
da7c40c78b Merge branch 'index_page' into implement_delete_account 2016-05-25 09:40:20 +02:00
00579eb542 Merge branch 'index_page' into implement_delete_account 2016-05-24 23:56:12 +02:00
ef55b4e479 First step 2016-05-24 23:55:58 +02:00
a6c5abfd88 Merge branch 'index_page' into 'development'
Index page



See merge request !10
2016-05-24 07:02:33 +02:00
293ff50809 Some more UI changes 2016-05-24 06:49:39 +02:00
873a28aa28 ui redesign 2016-05-22 11:47:46 +02:00
1c4d73da43 Adds start page for unauthed visitors 2016-05-18 08:19:11 +02:00
7eef2b6cee Adds start page for unauthed visitors 2016-05-18 08:17:53 +02:00
227dc79e6b Merge branch 'issue_1' into 'development'
Handle vehicle name uniqueness

Vehicle names now must be only unique per owner, not globally.
Also errors are displayed to the forms on creation and edit of
vehicles.

See merge request !9
2016-05-16 18:49:39 +02:00
19b1e3b7ae Handle vehicle name uniqueness
Vehicle names now must be only unique per owner, not globally.
Also errors are displayed to the forms on creation and edit of
vehicles.
2016-05-16 18:46:24 +02:00
98 changed files with 2691 additions and 845 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
.idea/
tests/results/
**.DS_Store
**.swp
**.pyc
*.sqlite

View File

@@ -1,12 +1,14 @@
FROM debian8_python3
COPY app/requirements.txt /requirements.txt
COPY requirements.txt /requirements.txt
RUN pip3 install -r /requirements.txt; \
mkdir /data
ADD app /app
ADD main.py /main.py
ADD config.py /config.py
VOLUME ["/data"]
VOLUME ["/app/config]
EXPOSE 5000
ENTRYPOINT python3 /app/main.py
ENTRYPOINT python3 /main.py

View File

@@ -11,6 +11,14 @@ a folder that will serve as configuration directory and fill in the
information. The directory will be used as volume during container
operation.
## start database
`docker run --name pitstops_db -e MYSQL_ROOT_PASSWORD=$SOMESECUREPASSWORD$ -e MYSQL_DATABASE=pitstops -d mysql:latest`
## Database migrations
### From *Cathrine* to *Master*:
`ALTER TABLE pitstop ADD COLUMN costs DECIMAL(5,2) NOT NULL DEFAULT 0.0 AFTER vehicle_id;`
## run in development
Include the development version of the code as volume, so the app gets
@@ -18,7 +26,6 @@ reloaded automatically. The sqlite file will be stored in *tmp* so it
can be inspected with tools like *sqlite3*. The switch *DEBUG* enables
debugging during development.
`docker run --rm --name rollerverbrauch -ti -v $PWD/app:/app -v $PWD/../rollerverbrauch_config:/app/config -v /tmp/pitstops/:/data -e DEBUG=True -p 5000:5000 rollerverbrauch`
`docker run --rm --name rollerverbrauch -ti -v $PWD/app:/app --link pitstops_db:database -p 5000:5000 -e SECURITY_PASSWORD_SALT=XXX -e SECRET_KEY=XXX -e MAIL_SERVER=XXX -e MAIL_USERNAME=XXX -e MAIL_PASSWORD=XXX rollerverbrauch`
## run in production
`docker run --name pitstops -d -v /data/pitstops/:/data -v /configs/pitstops/:/app/config -p 80:5000 rollerverbrauch`
`docker run --name pitstops -tid -e PROXY_DATA=server_names:ps.lusiardi.de,port:5000 --link pitstops_db:database -e SECURITY_PASSWORD_SALT=XXX -e SECRET_KEY=XXX -e MAIL_SERVER=XXX -e MAIL_USERNAME=XXX -e MAIL_PASSWORD=XXX rollerverbrauch:catherine`

95
app/__init__.py Normal file
View File

@@ -0,0 +1,95 @@
from flask import Flask
from flask import g
from flask_mail import Mail
from flask_security import Security, SQLAlchemyUserDatastore, user_registered
from flask_security.forms import LoginForm
from flask_sqlalchemy import SQLAlchemy
import os
from config import config
from flask.ext.security.forms import LoginForm
from .forms import *
app = Flask(__name__)
app.config.from_object(config[os.getenv('FLASK_CONFIG') or 'default'])
db = SQLAlchemy(app)
mail = Mail(app)
from .entities import *
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
security = Security(app, user_datastore)
# required to activate the filters
from .filters import *
from .tools import *
from .routes import *
@user_registered.connect_via(app)
def user_registered_sighandler(application, user, confirm_token):
"""
Called after a user was created
"""
role = user_datastore.find_role('user')
user_datastore.add_role_to_user(user, role)
if user.email == application.config['ADMIN_MAIL']:
# if the user selected the preconfigured email for the admin account
role = user_datastore.find_role('admin')
user_datastore.add_role_to_user(user, role)
new_vehicle = Vehicle('default vehicle')
db.session.add(new_vehicle)
user.vehicles.append(new_vehicle)
db.session.commit()
tools.db_log_add(user)
tools.db_log_add(new_vehicle)
@app.before_first_request
def before_first_request():
db.create_all()
user_datastore.find_or_create_role(name='admin', description='Role for administrators')
user_datastore.find_or_create_role(name='user', description='Role for all users.')
db.session.commit()
@app.before_request
def before_request():
g.data = {}
@app.route('/')
def index():
if current_user.is_authenticated:
return redirect(url_for('get_pit_stops'))
else:
user_count = len(User.query.all())
consumables = Consumable.query.all()
per_consumable = {}
for consumable in consumables:
per_consumable[consumable.id] = {
'name': consumable.name,
'unit': consumable.unit,
'amount': 0
}
vehicles = Vehicle.query.all()
kilometers = 0
for vehicle in vehicles:
stats = tools.VehicleStats(vehicle)
for consumable in stats.consumables:
per_consumable[consumable.id]['amount'] += consumable.overall_amount
kilometers += stats.overall_distance
vehicle_count = len(vehicles)
pitstop_count = len(Pitstop.query.all())
data = {
'users': user_count,
'vehicles': vehicle_count,
'pitstops': pitstop_count,
'kilometers': kilometers,
'consumables': per_consumable
}
return render_template('index.html', login_user_form=LoginForm(), data=data)

165
app/entities.py Normal file
View File

@@ -0,0 +1,165 @@
from app import db
from flask_security import UserMixin, RoleMixin
roles_users = db.Table('roles_users',
db.Column('user_id', db.Integer(), db.ForeignKey('user.id')),
db.Column('role_id', db.Integer(), db.ForeignKey('role.id')))
vehicles_consumables = db.Table('vehicles_consumables',
db.Column('vehicle_id', db.Integer(), db.ForeignKey('vehicle.id')),
db.Column('consumable_id', db.Integer(), db.ForeignKey('consumable.id')))
class Role(db.Model, RoleMixin):
"""
Entity to handle different roles for users: Typically user and admin exist
"""
id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(80), unique=True)
description = db.Column(db.String(255))
def __str__(self):
return self.name
def __hash__(self):
return hash(self.name)
class User(db.Model, UserMixin):
"""
Entity to represent a user including login data and links to roles and vehicles.
"""
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True)
password = db.Column(db.String(255))
active = db.Column(db.Boolean())
confirmed_at = db.Column(db.DateTime())
vehicles = db.relationship(
'Vehicle'
)
roles = db.relationship(
'Role',
secondary=roles_users,
backref=db.backref('users', lazy='dynamic')
)
def __repr__(self):
return '<User id="%r" email="%r" ' % (self.id, self.email)
class Vehicle(db.Model):
"""
Entity to represent a vehicle.
Attributes:
* name of the vehilce
* the id of the owner
* list of pitstops
* list of possible consumables
"""
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('user.id'))
name = db.Column(db.String(255))
pitstops = db.relationship(
'Pitstop'
)
services = db.relationship(
'Service'
)
consumables = db.relationship(
'Consumable',
secondary=vehicles_consumables
)
# allow vehicle names to be duplicated between different owners but must still be uniq for each owner
__table_args__ = (db.UniqueConstraint('owner_id',
'name',
name='_owner_name_uniq'),)
def __init__(self, name):
self.name = name
def __repr__(self):
return '<Vehicle id="%r" owner_id="%r" name="%r" />' % (self.id, self.owner_id, self.name)
class Pitstop(db.Model):
"""
Entity to represent a pitstop for a single consumable.
Attributes:
* the date of the pitstop
* the odometer of the pitstop
* the id of the fuelled consumable
* amount of consumable used
* the costs of the consumable
* the id of the vehicle that was refuelled
"""
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.Date)
odometer = db.Column(db.Integer)
consumable_id = db.Column(db.Integer, db.ForeignKey('consumable.id'))
amount = db.Column(db.Numeric(5, 2))
costs = db.Column(db.Numeric(5, 2), default=0)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'))
# short cut to access the fuelled consumable of the pitstop
consumable = db.relationship('Consumable')
# this uniqueness constraint makes sure that for each consumable and each vehicle only one pitstop exists at the
# same odometer
__table_args__ = (db.UniqueConstraint('odometer',
'consumable_id',
'vehicle_id',
name='_odometer_consumable_vehicle_uniq'),)
def __init__(self, odometer, amount, date, costs, consumable_id):
self.odometer = odometer
self.amount = amount
self.date = date
self.costs = costs
self.consumable_id = consumable_id
def __repr__(self):
return '<Pitstop odometer="%r" amount="%r" date="%r" vehicle_id="%r" consumable_id="%r">' % \
(self.odometer, self.amount, self.date, self.vehicle_id, self.consumable_id)
class Consumable(db.Model):
"""
Entity to represent a material that be consumed by a vehilce.
Attributes:
* name (must be globally unique)
* unit
"""
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True)
unit = db.Column(db.String(255))
vehicles = db.relationship(
'Vehicle',
secondary=vehicles_consumables
)
def __init__(self, name, unit):
self.name = name
self.unit = unit
def __repr__(self):
return '<Consumable name="%s" unit="%s" />' % (self.name, self.unit)
class Service(db.Model):
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.Date)
odometer = db.Column(db.Integer)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'))
costs = db.Column(db.Numeric(10, 2), default=0)
description = db.Column(db.String(4096))
def __init__(self, date, odometer, vehicle_id, costs, description):
self.description = description
self.costs = costs
self.date = date
self.odometer = odometer
self.vehicle_id = vehicle_id
def __repr__(self):
return '<Service odometer="%r" date="%r" vehicle_id="%r" costs="%r" description="%r">' % \
(self.odometer, self.date, self.vehicle_id, self.costs, self.description)

View File

@@ -1,5 +1,6 @@
from rollerverbrauch import app
from app import app
import hashlib
import markdown
@app.template_filter('none_filter')
@@ -20,3 +21,8 @@ def md5_filter(value):
@app.template_filter('str')
def str_filter(value):
return str(value)
@app.template_filter('markdown')
def md(value):
return markdown.markdown(value)

6
app/forms/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from .misc import *
from .pitstop import *
from .checks import *
from .consumable import *
from .service import *
from .vehicle import *

53
app/forms/checks.py Normal file
View File

@@ -0,0 +1,53 @@
from wtforms.validators import ValidationError
from datetime import date
def date_check(form, field):
"""
Checks that the date of the pitstop is not before the date of the latest pitstop and not after today.
:param form: the form where the field is in
:param field: the field to check
:return: Nothing or a ValidationError if the limits are not kept
"""
if field.data < form.last_pitstop.date:
raise ValidationError('The new date must not be before %s' % form.last_pitstop.date)
if field.data > date.today():
raise ValidationError('The new date must not be after %s' % date.today())
def odometer_check(form, field):
"""
:param form:
:param field:
:return:
"""
if not form.same_odometer_allowed and field.data <= form.last_pitstop.odometer:
raise ValidationError('The new odometer value must be higher than %i km' % form.last_pitstop.odometer)
if form.same_odometer_allowed and field.data < form.last_pitstop.odometer:
raise ValidationError('The new odometer value must be higher than %i km' % form.last_pitstop.odometer)
def litres_check(form, field):
if field.data is not None and field.data <= 0:
raise ValidationError('You must fuel at least 0.1 l')
def costs_check(form, field):
if field.data is not None and field.data <= 0:
raise ValidationError('Costs must be above 0.01 €.')
def edit_costs_check(form, field):
"""
Costs must be given, if a default value was given to the form field.
:param form:
:param field:
:return:
"""
costs_check_required = (form.costs.default is not None and form.costs.default > 0)
if costs_check_required and field.data is not None and field.data <= 0:
raise ValidationError('Costs must be above 0.01 €.')

24
app/forms/consumable.py Normal file
View File

@@ -0,0 +1,24 @@
from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, SubmitField
from wtforms.validators import Length
class SelectConsumableForm(FlaskForm):
consumable = SelectField('Consumable', coerce=int)
submit = SubmitField(label='Do it!')
class CreateConsumableForm(FlaskForm):
name = StringField('Name', validators=[Length(1, 255)])
unit = StringField('Unit', validators=[Length(1, 255)])
submit = SubmitField(label='Do it!')
class EditConsumableForm(FlaskForm):
name = StringField('Name', validators=[Length(1, 255)])
unit = StringField('Unit', validators=[Length(1, 255)])
submit = SubmitField(label='Do it!')
class DeletConsumableForm(FlaskForm):
submit = SubmitField(label='Do it!')

6
app/forms/misc.py Normal file
View File

@@ -0,0 +1,6 @@
from flask_wtf import FlaskForm
from wtforms import SubmitField
class DeleteAccountForm(FlaskForm):
submit = SubmitField(label='Really delete my account!')

82
app/forms/pitstop.py Normal file
View File

@@ -0,0 +1,82 @@
from flask_wtf import FlaskForm
from wtforms import DateField, IntegerField, DecimalField, SubmitField
from .checks import *
class DeletePitStopForm(FlaskForm):
submit = SubmitField(label='Really delete this pitstop!')
class EditPitstopForm(FlaskForm):
date = DateField('Date of Pitstop', validators=[date_check])
odometer = IntegerField('Odometer (km)', validators=[odometer_check])
litres = DecimalField('Litres (l)', places=2, validators=[litres_check])
costs = DecimalField('Costs (€, overall)', places=2, validators=[edit_costs_check])
submit = SubmitField(label='Update it!')
last_pitstop = None
same_odometer_allowed = True
def set_pitstop(self, last_pitstop):
self.last_pitstop = last_pitstop
def set_consumable(self, consumable):
self.litres.label = '%s (%s)' % (consumable.name, consumable.unit)
def preinit_with_data(self):
if self.date.data:
self.date.default = self.date.data
if self.odometer.data:
self.odometer.default = self.odometer.data
if self.litres.data:
self.litres.default = self.litres.data
if self.costs.data:
self.costs.default = self.costs.data
class CreatePitstopForm(FlaskForm):
date = DateField('Date of Pitstop', validators=[date_check])
odometer = IntegerField('Odometer (km)', validators=[odometer_check])
litres = DecimalField('Litres (l)', places=2, validators=[litres_check])
costs = DecimalField('Costs (€, overall)', places=2, validators=[costs_check])
submit = SubmitField(label='Do it!')
last_pitstop = None
same_odometer_allowed = True
def set_pitstop(self, last_pitstop):
self.last_pitstop = last_pitstop
def set_consumable(self, consumable):
self.litres.label = '%s (%s)' % (consumable.name, consumable.unit)
def preinit_with_data(self):
if self.date.data:
self.date.default = self.date.data
else:
self.date.default = date.today()
if self.odometer.data:
self.odometer.default = self.odometer.data
else:
self.odometer.default = self.last_pitstop.odometer
if self.litres.data:
self.litres.default = self.litres.data
else:
self.litres.default = self.last_pitstop.amount
if self.costs.data:
self.costs.default = self.costs.data
else:
self.costs.default = self.last_pitstop.costs
def get_hint_messages(self):
if self.same_odometer_allowed:
or_equal = ' or equal to'
else:
or_equal = ''
messages = {
'date': 'Date must be between %s and %s (including).' % (str(self.last_pitstop.date), str(date.today())),
'odometer': 'Odometer must be greater than%s %s km.' % (or_equal, str(self.last_pitstop.odometer)),
'costs': 'Costs must be higher than 0.01 €.'
}
return messages

68
app/forms/service.py Normal file
View File

@@ -0,0 +1,68 @@
from flask_wtf import FlaskForm
from wtforms import DateField, IntegerField, DecimalField, SubmitField, TextAreaField
from wtforms.validators import Length
from .checks import *
class CreateServiceForm(FlaskForm):
date = DateField('Date of Service', validators=[date_check])
odometer = IntegerField('Odometer (km)', validators=[odometer_check])
costs = DecimalField('Costs (€, overall)', places=2, validators=[costs_check])
description = TextAreaField('Description', validators=[Length(1, 4096)])
submit = SubmitField(label='Do it!')
last_pitstop = None
def set_pitstop(self, last_pitstop):
self.last_pitstop = last_pitstop
def preinit_with_data(self):
if self.date.data:
self.date.default = self.date.data
else:
self.date.default = date.today()
if self.odometer.data:
self.odometer.default = self.odometer.data
else:
self.odometer.default = self.last_pitstop.odometer
if self.costs.data:
self.costs.default = self.costs.data
else:
self.costs.default = 0
class DeleteServiceForm(FlaskForm):
submit = SubmitField(label='Really delete this service!')
class EditServiceForm(FlaskForm):
date = DateField('Date of Service', validators=[date_check])
odometer = IntegerField('Odometer (km)', validators=[odometer_check])
costs = DecimalField('Costs (€, overall)', places=2, validators=[costs_check])
description = TextAreaField('Description', validators=[Length(1, 4096)])
submit = SubmitField(label='Do it!')
last_pitstop = None
same_odometer_allowed = True
def set_pitstop(self, last_pitstop):
self.last_pitstop = last_pitstop
def preinit_with_data(self):
if self.date.data:
self.date.default = self.date.data
else:
self.date.default = date.today()
if self.odometer.data:
self.odometer.default = self.odometer.data
else:
self.odometer.default = self.last_pitstop.odometer
if self.costs.data:
self.costs.default = self.costs.data
else:
self.costs.default = 0

20
app/forms/vehicle.py Normal file
View File

@@ -0,0 +1,20 @@
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, SelectField, SelectMultipleField
from wtforms.validators import Length
class SelectVehicleForm(FlaskForm):
vehicle = SelectField('Vehicle', coerce=int)
submit = SubmitField(label='Do it!')
class EditVehicleForm(FlaskForm):
name = StringField('Name', validators=[Length(1, 255)])
consumables = SelectMultipleField('Consumables', coerce=int,validators=[])
submit = SubmitField(label='Do it!')
class DeleteVehicleForm(FlaskForm):
submit = SubmitField(label='Do it!')

View File

@@ -1,218 +0,0 @@
from datetime import date
from flask import Flask
from flask import redirect, g
from flask import render_template
from flask import url_for
from flask.ext.mail import Mail
from flask.ext.security import Security, SQLAlchemyUserDatastore, \
UserMixin, RoleMixin, login_required, roles_required
from flask.ext.security import user_registered
from flask_security.core import current_user
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config['SECURITY_PASSWORD_HASH'] = 'pbkdf2_sha512'
app.config['SECURITY_REGISTERABLE'] = True
app.config['SECURITY_CHANGEABLE'] = True
app.config['SECURITY_RECOVERABLE'] = True
app.config.from_envvar('config')
app.config.from_object(__name__)
db = SQLAlchemy(app)
mail = Mail(app)
from rollerverbrauch.tools import \
VehicleStats, \
db_log_add, \
db_log_delete, \
db_log_update
from rollerverbrauch.forms import \
CreatePitstopForm, \
EditVehicleForm, \
DeleteVehicleForm, \
SelectVehicleForm
from rollerverbrauch.entities import \
User, \
Role, \
Pitstop, \
Vehicle
# required to activate the filters
import rollerverbrauch.filters
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
security = Security(app, user_datastore)
@user_registered.connect_via(app)
def user_registered_sighandler(app, user, confirm_token):
"""
Called after a user was created
"""
role = user_datastore.find_role('user')
user_datastore.add_role_to_user(user, role)
new_vehicle = Vehicle('default vehicle')
db.session.add(new_vehicle)
user.vehicles.append(new_vehicle)
db.session.commit()
db_log_add(user)
db_log_add(new_vehicle)
@app.before_first_request
def before_first_request():
db.create_all()
user_datastore.find_or_create_role(name='admin', description='Role for administrators')
user_datastore.find_or_create_role(name='user', description='Role for all users.')
db.session.commit()
@app.before_request
def before_request():
g.data = {}
@app.route('/')
@login_required
def index():
return redirect(url_for('get_pit_stops'))
@app.route('/account/edit_vehicle/<int:vid>', methods=['GET', 'POST'])
@login_required
def edit_vehicle(vid):
vehicle = Vehicle.query.filter(Vehicle.id == vid).first()
form = EditVehicleForm()
if form.validate_on_submit():
vehicle.name = form.name.data
db.session.commit()
db_log_update(vehicle)
return redirect(url_for('get_account_page'))
form.name.default = vehicle.name
form.process()
return render_template('editVehicleForm.html', form=form)
@app.route('/account/delete_vehicle/<int:vid>', methods=['GET', 'POST'])
@login_required
def delete_vehicle(vid):
vehicle = Vehicle.query.filter(Vehicle.id == vid).first()
# prevent deletion of foreign vehicles
if vehicle not in current_user.vehicles:
return redirect(url_for('get_account_page'))
form = DeleteVehicleForm()
if form.validate_on_submit():
db.session.delete(vehicle)
db.session.commit()
db_log_delete(vehicle)
return redirect(url_for('get_account_page'))
return render_template('deleteVehicleForm.html', form=form, vehicle=vehicle)
@app.route('/account/create_vehicle', methods=['GET', 'POST'])
@login_required
def create_vehicle():
form = EditVehicleForm()
if form.validate_on_submit():
new_vehicle = Vehicle(form.name.data)
db.session.add(new_vehicle)
current_user.vehicles.append(new_vehicle)
db.session.commit()
db_log_add(new_vehicle)
return redirect(url_for('get_account_page'))
return render_template('createVehicleForm.html', form=form)
@app.route('/pitstops/select_vehicle', methods=['GET', 'POST'])
@login_required
def select_vehicle_for_new_pitstop():
form = SelectVehicleForm()
form.vehicle.choices = [(g.id, g.name) for g in current_user.vehicles]
if form.validate_on_submit():
vehicle = Vehicle.query.filter(Vehicle.id == form.vehicle.data).first()
if vehicle not in current_user.vehicles:
return render_template('selectVehice.html', form=form)
return redirect(url_for('create_pit_stop_form', vid=form.vehicle.data))
return render_template('selectVehice.html', form=form)
@app.route('/pitstops/create/<int:vid>', methods=['GET', 'POST'])
@login_required
def create_pit_stop_form(vid):
vehicle = Vehicle.query.filter(Vehicle.id == vid).first()
if vehicle not in current_user.vehicles:
return redirect(url_for('select_vehicle_for_new_pitstop'))
if len(vehicle.pitstops) > 0:
last_pitstop = vehicle.pitstops[-1]
else:
last_pitstop = Pitstop(0, 0, date(1970, 1, 1))
form = CreatePitstopForm()
form.set_pitstop(last_pitstop)
if form.validate_on_submit():
new_stop = Pitstop(form.odometer.data, form.litres.data, form.date.data)
db.session.add(new_stop)
vehicle.pitstops.append(new_stop)
db.session.commit()
db_log_add(new_stop)
return redirect(url_for('get_pit_stops', _anchor= 'v' + str(vehicle.id)))
form.odometer.default = last_pitstop.odometer
form.litres.default = last_pitstop.litres
form.date.default = date.today()
form.process()
messages = {
'date': 'Date must be between %s and %s (including).' % (str(last_pitstop.date), str(date.today())),
'odometer': 'Odometer must be greater than %s km.' % (str(last_pitstop.odometer))
}
return render_template('newPitStopForm.html', form=form, vehicle=vehicle, messages = messages)
@app.route('/pitstops', methods=['GET'])
@login_required
def get_pit_stops():
return render_template('pitstops.html', user=current_user)
@app.route('/manual', methods=['GET'])
@login_required
def get_manual():
return render_template('manual.html', data=g.data)
@app.route('/admin', methods=['GET'])
@roles_required('admin')
def get_admin_page():
g.data['users'] = User.query.all()
return render_template('admin.html', data=g.data)
@app.route('/account', methods=['GET'])
@login_required
def get_account_page():
return render_template('account.html', data=g.data)
@app.route('/statistics', methods=['GET'])
@login_required
def get_statistics():
stats = []
for vehicle in current_user.vehicles:
stats.append(VehicleStats(vehicle))
return render_template('statistics.html', data=stats)

View File

@@ -1,68 +0,0 @@
from rollerverbrauch import db
from flask.ext.security import UserMixin, RoleMixin
roles_users = db.Table('roles_users',
db.Column('user_id', db.Integer(), db.ForeignKey('user.id')),
db.Column('role_id', db.Integer(), db.ForeignKey('role.id')))
class Role(db.Model, RoleMixin):
id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(80), unique=True)
description = db.Column(db.String(255))
def __str__(self):
return self.name
def __hash__(self):
return hash(self.name)
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True)
password = db.Column(db.String(255))
active = db.Column(db.Boolean())
confirmed_at = db.Column(db.DateTime())
vehicles = db.relationship(
'Vehicle'
)
roles = db.relationship(
'Role',
secondary=roles_users,
backref=db.backref('users', lazy='dynamic')
)
def __repr__(self):
return '<User id="%r" email="%r" ' % (self.id, self.email)
class Vehicle(db.Model):
id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('user.id'))
name = db.Column(db.String(255), unique=True)
pitstops = db.relationship(
'Pitstop'
)
def __init__(self, name):
self.name = name
def __repr__(self):
return '<Vehicle id="%r" owner_id="%r" name="%r" />' % (self.id, self.owner_id, self.name)
class Pitstop(db.Model):
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.Date)
odometer = db.Column(db.Integer)
litres = db.Column(db.Numeric(5, 2))
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id'))
def __init__(self, odometer, litres, date):
self.odometer = odometer
self.litres = litres
self.date = date
def __repr__(self):
return '<Pitstop odometer="%r" litres="%r" date="%r" vehicle_id="%r">' % (self.odometer, self.litres, self.date, self.vehicle_id)

View File

@@ -1,46 +0,0 @@
from flask_wtf import Form
from wtforms import DateField, IntegerField, DecimalField, StringField, SelectField, SubmitField
from wtforms.validators import ValidationError, Length
from datetime import date
def date_check(form, field):
if field.data < form.last_pitstop.date:
raise ValidationError('The new date must not be before %s' % form.last_pitstop.date)
if field.data > date.today():
raise ValidationError('The new date must not be after %s' % date.today())
def odometer_check(form, field):
if field.data <= form.last_pitstop.odometer:
raise ValidationError('The new odometer value must be higher than %i km' % form.last_pitstop.odometer)
def litres_check(form, field):
if field.data is not None and field.data <= 0:
raise ValidationError('You must fuel at least 0.1 l')
class SelectVehicleForm(Form):
vehicle = SelectField('Vehicle', coerce=int)
submit = SubmitField(label='Do it!')
class CreatePitstopForm(Form):
date = DateField('Date of Pitstop', validators=[date_check])
odometer = IntegerField('Odometer (km)', validators=[odometer_check])
litres = DecimalField('Litres (l)', places=2, validators=[litres_check])
submit = SubmitField(label='Do it!')
last_pitstop = None
def set_pitstop(self, last_pitstop):
self.last_pitstop = last_pitstop
class EditVehicleForm(Form):
name = StringField('Name', validators=[Length(1, 255)])
submit = SubmitField(label='Do it!')
class DeleteVehicleForm(Form):
submit = SubmitField(label='Do it!')

View File

@@ -1,30 +0,0 @@
body {
padding-top: 50px;
}
.starter-template {
padding: 40px 15px;
text-align: center;
}
th {
text-align: center;
padding-left: 10px;
padding-right: 10px;
}
td {
text-align: right;
padding-left: 10px;
padding-right: 10px;
}
.error {
color: #a94442;
}
.pitstop {
}
.nav-pills > li > a {
border: 1px solid;
}

View File

@@ -1,56 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<h3>Account management for {{current_user.email}}</h3>
<div class="panel panel-default">
<div class="panel-heading">Password</div>
<div class="panel-body">
<a href='{{ url_for('security.change_password') }}'>
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Change
</a>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">Vehicles</div>
<div class="panel-body">
<a href="{{ url_for('create_vehicle') }}">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> create
</a>
</div>
<table class="table table-striped table-bordered">
<tr>
<th>
Vehicle
</th>
<th>
Info
</th>
<th>
Actions
</th>
</tr>
{% for vehicle in current_user.vehicles %}
<tr>
<td style="text-align:center">
{{ vehicle.name }}
</td>
<td style="text-align:center">
{{ vehicle.pitstops | length }} pitstops
</td>
<td style="text-align:center">
<a href="{{ url_for('edit_vehicle', vid=vehicle.id) }}">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
{% if current_user.vehicles | length > 1 %}
<a href="{{ url_for('delete_vehicle', vid=vehicle.id) }}">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> delete
</a>
{% else %}
&nbsp;
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<h3>Admin</h3>
We have {{ data.users|length }} users so far:
<ul>
{% for user in data.users %}
<li>{{user.email}}</li>
{% endfor %}
</ul>
<a href='{{ url_for('security.login', _external=True) }}'>Login</a>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<h3>Create vehicle</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.name) }}
{{ render_field_with_errors(form.submit) }}
</form>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<h3>Delete vehicle '{{vehicle.name}}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.submit) }}
</form>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<h3>Edit vehicle</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.name) }}
{{ render_field_with_errors(form.submit) }}
</form>
{% endblock %}

View File

@@ -1,20 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<h3>New Pitstop for '{{ vehicle.name }}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.date) }}
<span id="{{form.date.id}}_help" class="help-block">
{{messages['date']}}
</span>
{{ render_field_with_errors(form.odometer) }}
<span id="{{form.odometer.id}}_help" class="help-block">
{{messages['odometer']}}
</span>
{{ render_field_with_errors(form.litres) }}
{{ render_field_with_errors(form.submit) }}
</form>
{% endblock %}

View File

@@ -1,86 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<div id="content">
<ul id="tabs" class="nav nav-tabs" data-tabs="tabs">
{% for vehicle in current_user.vehicles %}
<li {% if loop.first %}class="active" {%endif %}>
<a href="#v{{vehicle.id}}" id="i{{vehicle.id}}" data-toggle="tab">
{{ vehicle.name }}
</a>
</li>
{% endfor %}
</ul>
<div id="my-tab-content" class="tab-content">
{% for vehicle in current_user.vehicles %}
<div class="tab-pane {% if loop.first %}active{% endif %}" id="v{{vehicle.id}}">
<h3>{{vehicle.name}}</h3>
<div class="table-responsive">
<table class="table table-striped table-bordered table-condensed">
<tr>
<th>
Date<br/>
Days
</th>
<th>
Odometer<br/>
Distance
</th>
<th>
Litres<br/>
Average
</th>
</tr>
{% for pitstop in vehicle.pitstops|reverse %}
{% if not loop.last %}
{% set days = (pitstop.date - vehicle.pitstops[vehicle.pitstops|length - loop.index - 1].date).days %}
{% set distance = pitstop.odometer - vehicle.pitstops[vehicle.pitstops|length - loop.index - 1].odometer %}
{% set average = (pitstop.litres / distance) * 100 %}
<tr class='pitstop'>
<td>
{{pitstop.date}}<br/>
{{ days }} days
</td>
<td>
{{pitstop.odometer}} km<br/>
{{distance}} km
</td>
<td>
{{pitstop.litres}} l<br/>
{{average | round(2)}} l/100km
</td>
</tr>
{% else %}
<tr class='pitstop'>
<td>
{{pitstop.date}}<br/>
-- days
</td>
<td>
{{pitstop.odometer}} km<br/>
-- km
</td>
<td>
{{pitstop.litres}} l<br/>
-- l/100km
</td>
</tr>
{% endif %}
{% endfor %}
</table>
</div>
</div>
{% endfor %}
</div>
</div>
<script type="text/javascript">
jQuery(document).ready(function ($) {
$('#tabs').tab();
if(window.location.hash != "") {
$('a[href="' + window.location.hash + '"]').click()
}
});
</script>
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends "layout.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}
{% block body %}
<h1>Change password</h1>
<form class='form-horizontal' action="{{ url_for_security('change_password') }}" method="POST" name="change_password_form">
{{ change_password_form.hidden_tag() }}
{{ render_field_with_errors(change_password_form.password) }}
{{ render_field_with_errors(change_password_form.new_password) }}
{{ render_field_with_errors(change_password_form.new_password_confirm) }}
{{ render_field_with_errors(change_password_form.submit) }}
</form>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "layout.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}
{% block body %}
<h1>Send password reset instructions</h1>
<form class='form-horizontal' action="{{ url_for_security('forgot_password') }}" method="POST" name="forgot_password_form">
{{ forgot_password_form.hidden_tag() }}
{{ render_field_with_errors(forgot_password_form.email) }}
{{ render_field_with_errors(forgot_password_form.submit) }}
</form>
{% endblock %}

View File

@@ -1,17 +0,0 @@
{% extends "layout.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}
{% block body %}
<h1>Login</h1>
<form class='form-horizontal' action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
{{ login_user_form.hidden_tag() }}
{{ render_field_with_errors(login_user_form.email) }}
{{ render_field_with_errors(login_user_form.password) }}
{{ render_field_with_errors(login_user_form.remember) }}
{{ render_field(login_user_form.next) }}
{{ render_field_with_errors(login_user_form.submit) }}
{% if security.recoverable %}
<a href="{{ url_for_security('forgot_password') }}">Forgot password</a>
{% endif %}
</form>
{% endblock %}

View File

@@ -1,15 +0,0 @@
{% extends "layout.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}
{% block body %}
<h1>Register User</h1>
<form class='form-horizontal' action="{{ url_for_security('register') }}" method="POST" name="register_user_form">
{{ register_user_form.hidden_tag() }}
{{ render_field_with_errors(register_user_form.email) }}
{{ render_field_with_errors(register_user_form.password) }}
{% if register_user_form.password_confirm %}
{{ render_field_with_errors(register_user_form.password_confirm) }}
{% endif %}
{{ render_field_with_errors(register_user_form.submit) }}
</form>
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<h3>Select Vehicle</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.vehicle) }}
{{ render_field_with_errors(form.submit) }}
</form>
{% endblock %}

View File

@@ -1,113 +0,0 @@
{% extends "layout.html" %}
{% block body %}
<div id="content">
<ul id="vehicle_tabs" class="nav nav-tabs" data-tabs="tabs">
{% for vehicle in data %}
<li {% if loop.first %}class="active" {%endif %}>
<a href="#v{{vehicle.id}}" id="i{{vehicle.id}}" data-toggle="tab">
{{ vehicle.name }}
</a>
</li>
{% endfor %}
</ul>
<div id="vehicle_contetn" class="tab-content">
{% for vehicle in data %}
<div class="tab-pane {% if loop.first %}active{% endif %}" id="v{{vehicle.id}}">
<h3>{{vehicle.name}}</h3>
<div class="table-responsive">
<table class="table table-striped table-bordered table-condensed">
<tr>
<th>Number of Pitstops:</th>
<td>{{ vehicle.pitstop_count }}</td>
</tr>
<tr>
<th>Logged Distance:</th>
<td>{{ vehicle.overall_distance | round(2) }} km</td>
</tr>
<tr>
<th>Average Distance:</th>
<td>{{ vehicle.average_distance | round(2) }} km</td>
</tr>
<tr>
<th>Litres fuelled:</th>
<td>{{ vehicle.overall_litres | round(2) }} l</td>
</tr>
<tr>
<th>Average Litres fuelled:</th>
<td>{{ vehicle.average_litres_fuelled | round(2) }} l</td>
</tr>
<tr>
<th>Average Litres used:</th>
<td>{{ vehicle.average_litres_used | round(2) }} l/100km</td>
</tr>
</table>
</div>
<ul id="charts_tabs" class="nav nav-tabs" data-tabs="tabs">
<li class="active">
<a href="#v{{vehicle.id}}_c1" id="i{{vehicle.id}}_c1" data-toggle="tab">
Fuelled litres
</a>
</li>
<li>
<a href="#v{{vehicle.id}}_c2" id="i{{vehicle.id}}_c2" data-toggle="tab">
Odometer
</a>
</li>
<li>
<a href="#v{{vehicle.id}}_c3" id="i{{vehicle.id}}_c3" data-toggle="tab">
Consumption
</a>
</li>
</ul>
<div id="my-tab-content" class="tab-content">
<div class="tab-pane active" id="v{{vehicle.id}}_c1">
{% if vehicle.pitstop_count > 0 %}
<div id="fuelledChartDiv{{vehicle.id}}" style="width:100%; height:500px;"></div>
<script type="text/javascript">
{{ chartScript('fuelledChartDiv'+vehicle.id|str, vehicle.litres, 'l') }}
</script>
{% else %}
not enough data.
{% endif %}
</div>
<div class="tab-pane " id="v{{vehicle.id}}_c2">
{% if vehicle.pitstop_count > 0 %}
<div id="odometerChartDiv{{vehicle.id}}" style="width:100%; height:500px;"></div>
<script type="text/javascript">
{{ chartScript('odometerChartDiv'+vehicle.id|str, vehicle.odometers, 'km') }}
</script>
{% else %}
not enough data.
{% endif %}
</div>
<div class="tab-pane " id="v{{vehicle.id}}_c3">
{% if vehicle.pitstop_count > 1 %}
<div id="averageUsageDiv{{vehicle.id}}" style="width:100%; height:500px;"></div>
<script type="text/javascript">
{{ chartScript('averageUsageDiv'+vehicle.id|str, vehicle.average_litres, 'l/100 km') }}
</script>
{% else %}
not enough data.
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script type="text/javascript">
jQuery(document).ready(function ($) {
$('#vehicle_tabs').tab();
if(window.location.hash != "") {
$('a[href="' + window.location.hash + '"]').click()
}
});
</script>
{% endblock %}

View File

@@ -1,49 +0,0 @@
import logging
class VehicleStats:
def __init__(self, vehicle):
self.name = vehicle.name
self.id = vehicle.id
self.pitstop_count = len(vehicle.pitstops)
self.overall_distance = 0
self.average_distance = 0
self.overall_litres = 0
self.average_litres_fuelled = 0
self.average_litres_used = 0
self.litres = []
self.average_litres = []
self.odometers = []
if self.pitstop_count > 0:
for pitstop in vehicle.pitstops:
self.overall_litres += pitstop.litres
self.litres.append(StatsEvent(pitstop.date, pitstop.litres))
self.odometers.append(StatsEvent(pitstop.date, pitstop.odometer))
self.average_litres_fuelled = self.overall_litres / self.pitstop_count
if self.pitstop_count > 1:
self.overall_distance = vehicle.pitstops[-1].odometer - vehicle.pitstops[0].odometer
self.average_distance = self.overall_distance / (self.pitstop_count - 1)
self.average_litres_used = 100 * (self.overall_litres - vehicle.pitstops[0].litres) / self.overall_distance
for index in range(1, self.pitstop_count):
last_ps = vehicle.pitstops[index - 1]
current_ps = vehicle.pitstops[index]
self.average_litres.append(StatsEvent(current_ps.date, round(100 * current_ps.litres/(current_ps.odometer - last_ps.odometer),2)))
class StatsEvent:
def __init__(self, date, value):
self.date = date
self.value = value
def db_log_add(entity):
logging.info('db_add: %s' % str(entity))
def db_log_delete(entity):
logging.info('db_delete: %s' % str(entity))
def db_log_update(entity):
logging.info('db_update: %s' % str(entity))

5
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .account import *
from .admin import *
from .misc import *
from .pitstop import *
from .service import *

131
app/routes/account.py Normal file
View File

@@ -0,0 +1,131 @@
from flask import url_for, redirect, render_template
from flask_security import login_required
from flask_security.core import current_user
from sqlalchemy.exc import IntegrityError
from ..entities import Vehicle, Consumable
from ..forms import EditVehicleForm, DeleteVehicleForm, DeleteAccountForm
from ..tools import db_log_update, db_log_delete, db_log_add
from .. import app, db, user_datastore
@app.route('/account', methods=['GET'])
@login_required
def get_account_page():
return render_template('account.html')
@app.route('/account/vehicle/edit/<int:vid>', methods=['GET', 'POST'])
@login_required
def edit_vehicle(vid):
vehicle = Vehicle.query.filter(Vehicle.id == vid).first()
# prevent edit of foreign vehicles
if vehicle not in current_user.vehicles:
return redirect(url_for('get_account_page'))
form = EditVehicleForm()
form.consumables.choices = [(g.id, g.name) for g in Consumable.query.all()]
if not form.consumables.data:
form.consumables.default = [g.id for g in vehicle.consumables]
if form.name.data is not None:
form.name.default = form.name.data
if form.validate_on_submit():
vehicle.name = form.name.data
# we cannot delete consumables where there are pitstops for => report error
vehicle.consumables = []
for consumable_id in form.consumables.data:
consumable = Consumable.query.get(consumable_id)
if consumable is not None:
vehicle.consumables.append(consumable)
try:
db.session.commit()
db_log_update(vehicle)
except IntegrityError:
db.session.rollback()
form.name.errors.append('"%s" is not unique.' % (form.name.data))
return render_template('editVehicleForm.html', form=form)
return redirect(url_for('get_account_page'))
form.name.default = vehicle.name
form.process()
return render_template('editVehicleForm.html', form=form, vehicle=vehicle)
@app.route('/account/vehicle/delete/<int:vid>', methods=['GET', 'POST'])
@login_required
def delete_vehicle(vid):
vehicle = Vehicle.query.filter(Vehicle.id == vid).first()
# prevent deletion of foreign vehicles
if vehicle not in current_user.vehicles:
return redirect(url_for('get_account_page'))
if len(current_user.vehicles) == 1:
return redirect(url_for('get_account_page'))
form = DeleteVehicleForm()
if form.validate_on_submit():
db.session.delete(vehicle)
db.session.commit()
db_log_delete(vehicle)
return redirect(url_for('get_account_page'))
return render_template('deleteVehicleForm.html', form=form, vehicle=vehicle)
@app.route('/account/vehicle/create', methods=['GET', 'POST'])
@login_required
def create_vehicle():
form = EditVehicleForm()
form.consumables.choices = [(g.id, g.name) for g in Consumable.query.all()]
if form.name.data is not None:
form.name.default = form.name.data
if form.consumables.data:
form.consumables.default = form.consumables.data
else:
form.consumables.default = []
if form.validate_on_submit():
if len(form.consumables.data) == 0:
form.consumables.errors.append('At least one consumable must be selected.')
return render_template('createVehicleForm.html', form=form)
vehicle_name = form.name.data
new_vehicle = Vehicle(vehicle_name)
for consumable_id in form.consumables.data:
consumable = Consumable.query.get(consumable_id)
if consumable is not None:
new_vehicle.consumables.append(consumable)
db.session.add(new_vehicle)
current_user.vehicles.append(new_vehicle)
try:
db.session.commit()
db_log_add(new_vehicle)
except IntegrityError:
db.session.rollback()
form.name.errors.append('"%s" is not unique.' % (form.name.data))
return render_template('createVehicleForm.html', form=form)
return redirect(url_for('get_account_page'))
return render_template('createVehicleForm.html', form=form)
@app.route('/account/delete', methods=['GET', 'POST'])
@login_required
def delete_account():
form = DeleteAccountForm()
if form.validate_on_submit():
user_datastore.delete_user(current_user)
db.session.commit()
return redirect(url_for('index'))
return render_template('deleteAccountForm.html', form=form)

96
app/routes/admin.py Normal file
View File

@@ -0,0 +1,96 @@
from flask import url_for, redirect, render_template
from flask_security import login_required, roles_required
from sqlalchemy.exc import IntegrityError
from ..entities import User, Consumable
from ..forms import CreateConsumableForm, DeletConsumableForm, EditConsumableForm
from ..tools import db_log_update, db_log_delete, db_log_add
from .. import app, db
@app.route('/admin', methods=['GET'])
@roles_required('admin')
def get_admin_page():
users = User.query.all()
consumables = Consumable.query.all()
for consumable in consumables:
consumable.in_use = len(consumable.vehicles) > 0
return render_template('admin.html', users=users, consumables=consumables)
@app.route('/admin/consumable/create', methods=['GET', 'POST'])
@login_required
def create_consumable():
form = CreateConsumableForm()
# preinitialize the defaults with potentially existing values from a try before
if form.name.data is not None:
form.name.default = form.name.data
if form.unit.data is not None:
form.unit.default = form.unit.data
if form.validate_on_submit():
new_consumable = Consumable(form.name.data, form.unit.data)
db.session.add(new_consumable)
try:
db.session.commit()
db_log_add(new_consumable)
except IntegrityError:
db.session.rollback()
form.name.errors.append('"%s" is not unique.' % (form.name.data))
return render_template('createConsumableForm.html', form=form)
return redirect(url_for('get_admin_page'))
return render_template('createConsumableForm.html', form=form)
@app.route('/admin/consumable/delete/<int:cid>', methods=['GET', 'POST'])
@login_required
def delete_consumable(cid):
consumable = Consumable.query.filter(Consumable.id == cid).first()
if consumable is None:
return redirect(url_for('get_admin_page'))
form = DeletConsumableForm()
if form.validate_on_submit():
db.session.delete(consumable)
db.session.commit()
db_log_delete(consumable)
return redirect(url_for('get_admin_page'))
return render_template('deleteConsumableForm.html', form=form, consumable=consumable)
@app.route('/admin/consumable/edit/<int:cid>', methods=['GET', 'POST'])
@login_required
def edit_consumable(cid):
consumable = Consumable.query.filter(Consumable.id == cid).first()
if consumable is None:
return redirect(url_for('get_admin_page'))
form = EditConsumableForm()
form.name.default = consumable.name
form.unit.default = consumable.unit
# preinitialize the defaults with potentially existing values from a try before
if form.name.data is not None:
form.name.default = form.name.data
if form.unit.data is not None:
form.unit.default = form.unit.data
if form.validate_on_submit():
consumable.name = form.name.data
consumable.unit = form.unit.data
try:
db.session.commit()
db_log_update(consumable)
except IntegrityError:
db.session.rollback()
form.name.errors.append('"%s" is not unique.' % (form.name.data))
return render_template('editConsumableForm.html', form=form)
return redirect(url_for('get_admin_page'))
return render_template('editConsumableForm.html', form=form)

22
app/routes/misc.py Normal file
View File

@@ -0,0 +1,22 @@
from flask import render_template
from flask_security import login_required
from flask_security.core import current_user
from ..tools import VehicleStats
from .. import app
@app.route('/statistics', methods=['GET'])
@login_required
def get_statistics():
stats = []
for vehicle in current_user.vehicles:
stats.append(VehicleStats(vehicle))
return render_template('statistics.html', data=stats)
@app.route('/manual', methods=['GET'])
@login_required
def get_manual():
return render_template('manual.html')

184
app/routes/pitstop.py Normal file
View File

@@ -0,0 +1,184 @@
from flask import url_for, redirect, render_template, flash
from flask_security import login_required
from flask_security.core import current_user
from sqlalchemy.exc import IntegrityError
from datetime import date
from ..entities import Vehicle, Consumable, Pitstop
from ..forms import SelectVehicleForm, SelectConsumableForm, CreatePitstopForm, EditPitstopForm, DeletePitStopForm
from ..tools import db_log_update, db_log_delete, db_log_add, get_latest_pitstop_for_vehicle, \
get_latest_pitstop_for_vehicle_and_consumable, compute_lower_limits_for_new_pitstop, pitstop_service_key, \
get_event_line_for_vehicle
from .. import app, db
@app.route('/pitstops/vehicle/select', methods=['GET', 'POST'])
@login_required
def select_vehicle_for_new_pitstop():
if len(current_user.vehicles) == 1:
return redirect(url_for('select_consumable_for_new_pitstop', vid=current_user.vehicles[0].id))
form = SelectVehicleForm()
form.vehicle.choices = [(g.id, g.name) for g in current_user.vehicles]
if form.validate_on_submit():
return redirect(url_for('select_consumable_for_new_pitstop', vid=form.vehicle.data))
return render_template('selectVehicle.html', form=form)
@app.route('/pitstops/vehicle/<int:vid>/consumable/select', methods=['GET', 'POST'])
@login_required
def select_consumable_for_new_pitstop(vid):
vehicle = Vehicle.query.get(vid)
if vehicle is None or vehicle not in current_user.vehicles:
return redirect(url_for('select_vehicle_for_new_pitstop'))
if len(vehicle.consumables) == 0:
flash('Please choose at least one consumable!', 'warning')
return redirect(url_for('edit_vehicle', vid=vid))
if len(vehicle.consumables) == 1:
return redirect(url_for('create_pit_stop_form', vid=vid, cid=vehicle.consumables[0].id))
form = SelectConsumableForm()
form.consumable.choices = [(g.id, g.name) for g in vehicle.consumables]
if form.validate_on_submit():
return redirect(url_for('create_pit_stop_form', vid=vid, cid=form.consumable.data))
return render_template('selectConsumableForVehicle.html', vehicle=vehicle, form=form)
@app.route('/pitstops/vehicle/<int:vid>/consumable/<int:cid>/create', methods=['GET', 'POST'])
@login_required
def create_pit_stop_form(vid, cid):
vehicle = Vehicle.query.get(vid)
if vehicle is None or vehicle not in current_user.vehicles:
return redirect(url_for('select_vehicle_for_new_pitstop'))
consumable = Consumable.query.get(cid)
if consumable not in vehicle.consumables:
return redirect(url_for('select_consumable_for_new_pitstop', vid=vid))
form = CreatePitstopForm()
data = get_event_line_for_vehicle(vehicle)
if len(data) > 0:
form.set_pitstop(Pitstop(data[-1].odometer, 0, data[-1].date, 0, cid))
form.same_odometer_allowed = (type(data[-1]) != Pitstop) or (data[-1].consumable.id != cid)
else:
form.set_pitstop(Pitstop(date(1970, 1, 1), 0, vid, 0, ''))
form.set_pitstop(Pitstop(0, 0, date(1970, 1, 1), 0, cid))
form.same_odometer_allowed = True
# set the label of the litres field to make the user comfortable
form.set_consumable(consumable)
# preinitialize the defaults with potentially existing values from a try before
form.preinit_with_data()
#
# Validate should accept same odometer on different consumables
#
if form.validate_on_submit():
new_stop = Pitstop(form.odometer.data, form.litres.data, form.date.data, form.costs.data, cid)
db.session.add(new_stop)
vehicle.pitstops.append(new_stop)
try:
db.session.commit()
db_log_add(new_stop)
except IntegrityError:
db.session.rollback()
form.odometer.errors.append('Pitstop already present for %s at odometer %s km!' % (consumable.name, form.odometer.data))
return render_template('createPitStopForm.html', form=form, vehicle=vehicle, messages=form.get_hint_messages())
return redirect(url_for('get_pit_stops', _anchor= 'v' + str(vehicle.id)))
form.process()
return render_template('createPitStopForm.html', form=form, vehicle=vehicle, messages=form.get_hint_messages())
@app.route('/pitstops/delete/<int:pid>', methods=['GET', 'POST'])
@login_required
def delete_pit_stop_form(pid):
pitstop = Pitstop.query.filter(Pitstop.id == pid).first()
if pitstop is None:
return redirect(url_for('get_pit_stops'))
vehicle = Vehicle.query.filter(Vehicle.id == pitstop.vehicle_id).first()
if vehicle not in current_user.vehicles:
return redirect(url_for('get_pit_stops'))
form = DeletePitStopForm()
if form.validate_on_submit():
db.session.delete(pitstop)
db.session.commit()
db_log_delete(pitstop)
return redirect(url_for('get_pit_stops', _anchor='v' + str(vehicle.id)))
return render_template('deletePitstopForm.html', form=form, pitstop=pitstop )
@app.route('/pitstops/edit/<int:pid>', methods=['GET', 'POST'])
@login_required
def edit_pit_stop_form(pid):
edit_pitstop = Pitstop.query.get(pid)
if edit_pitstop is None:
return redirect(url_for('get_pit_stops'))
vehicle = Vehicle.query.filter(Vehicle.id == edit_pitstop.vehicle_id).first()
if vehicle not in current_user.vehicles:
return redirect(url_for('get_pit_stops'))
last_pitstop_pos = vehicle.pitstops.index(edit_pitstop) - 1
if last_pitstop_pos > 0:
last_pitstop = vehicle.pitstops[last_pitstop_pos]
else:
last_pitstop = Pitstop(0, 0, date(1970, 1, 1), 0, 0)
form = EditPitstopForm()
form.set_pitstop(last_pitstop)
if form.validate_on_submit():
edit_pitstop.costs = form.costs.data
edit_pitstop.date = form.date.data
edit_pitstop.amount = form.litres.data
edit_pitstop.odometer = form.odometer.data
db.session.commit()
db_log_update(edit_pitstop)
return redirect(url_for('get_pit_stops', _anchor='v' + str(vehicle.id)))
form.odometer.default = edit_pitstop.odometer
form.litres.default = edit_pitstop.amount
form.date.default = edit_pitstop.date
form.costs.default = edit_pitstop.costs
form.process()
messages = {
'date': 'Date must be between %s and %s (including).' % (str(last_pitstop.date), str(date.today())),
'odometer': 'Odometer must be greater than %s km.' % (str(last_pitstop.odometer))
}
if edit_pitstop.costs is not None and edit_pitstop.costs > 0:
messages['costs'] = 'Costs must be higher than 0.01 €.'
return render_template('editPitStopForm.html', form=form, vehicle=vehicle, messages=messages)
@app.route('/pitstops', methods=['GET'])
@login_required
def get_pit_stops():
user = {
'vehicles': []
}
for vehicle in current_user.vehicles:
data = []
for pitstop in vehicle.pitstops:
data.append(pitstop)
for service in vehicle.services:
data.append(service)
data.sort(key=pitstop_service_key)
v = {
'id': vehicle.id,
'name': vehicle.name,
'data': data
}
user['vehicles'].append(v)
return render_template('pitstops.html', user=user)

120
app/routes/service.py Normal file
View File

@@ -0,0 +1,120 @@
from flask import url_for, redirect, render_template
from flask_security import login_required, current_user
from datetime import date
from ..entities import Vehicle, Service
from ..forms import CreateServiceForm, DeleteServiceForm, EditServiceForm, SelectVehicleForm
from ..tools import db_log_update, db_log_delete, get_event_line_for_vehicle, get_latest_pitstop_for_vehicle
from .. import app, db
@app.route('/service/vehicle/<int:vid>/create', methods=['GET', 'POST'])
@login_required
def create_service_for_vehicle(vid):
vehicle = Vehicle.query.get(vid)
if vehicle is None or vehicle not in current_user.vehicles:
return redirect(url_for('get_account_page'))
form = CreateServiceForm()
data = get_event_line_for_vehicle(vehicle)
if len(data) > 0:
form.set_pitstop(Service(data[-1].date, data[-1].odometer, vid, 0, ''))
form.same_odometer_allowed = type(data[-1]) != Service
else:
form.set_pitstop(Service(date(1970, 1, 1), 0, vid, 0, ''))
form.same_odometer_allowed = True
form.preinit_with_data()
if form.validate_on_submit():
new_service = Service(form.date.data, form.odometer.data, vid, form.costs.data, form.description.data)
db.session.add(new_service)
vehicle.services.append(new_service)
db.session.commit()
return redirect(url_for('get_pit_stops', _anchor='v' + str(vehicle.id)))
form.process()
return render_template('createServiceForm.html', form=form, vehicle=vehicle, messages=[])
@app.route('/service/delete/<int:sid>', methods=['GET', 'POST'])
@login_required
def delete_service_form(sid):
service = Service.query.filter(Service.id == sid).first()
if service is None:
return redirect(url_for('get_pit_stops'))
vehicle = Vehicle.query.filter(Vehicle.id == service.vehicle_id).first()
if vehicle not in current_user.vehicles:
return redirect(url_for('get_pit_stops'))
form = DeleteServiceForm()
if form.validate_on_submit():
db.session.delete(service)
db.session.commit()
db_log_delete(service)
return redirect(url_for('get_pit_stops', _anchor='v' + str(vehicle.id)))
return render_template('deleteServiceForm.html', form=form, service=service )
@app.route('/service/edit/<int:sid>', methods=['GET', 'POST'])
@login_required
def edit_service_form(sid):
edit_service = Service.query.get(sid)
if edit_service is None:
return redirect(url_for('get_pit_stops'))
vehicle = Vehicle.query.filter(Vehicle.id == edit_service.vehicle_id).first()
if vehicle not in current_user.vehicles:
return redirect(url_for('get_pit_stops'))
form = EditServiceForm()
data = get_event_line_for_vehicle(vehicle)
data.reverse()
if len(data) > 0:
last_pitstop = Service(data[-1].date, data[-1].odometer, vehicle.id, 0, '')
else:
last_pitstop = Service(date(1970, 1, 1), 0, vehicle.id, 0, '')
form.set_pitstop(last_pitstop)
form.same_odometer_allowed = True
if form.validate_on_submit():
edit_service.costs = form.costs.data
edit_service.date = form.date.data
edit_service.description = form.description.data
edit_service.odometer = form.odometer.data
db.session.commit()
db_log_update(edit_service)
return redirect(url_for('get_pit_stops', _anchor='v' + str(vehicle.id)))
form.odometer.default = edit_service.odometer
form.description.default = edit_service.description
form.date.default = edit_service.date
form.costs.default = edit_service.costs
form.process()
messages = {
'date': 'Date must be between %s and %s (including).' % (str(last_pitstop.date), str(date.today())),
'odometer': 'Odometer must be greater than %s km.' % (str(last_pitstop.odometer))
}
if edit_service.costs is not None and edit_service.costs > 0:
messages['costs'] = 'Costs must be higher than 0.01 €.'
return render_template('editServiceForm.html', form=form, vehicle=vehicle, messages=messages)
@app.route('/service/vehicle/select', methods=['GET', 'POST'])
@login_required
def select_vehicle_for_new_service():
if len(current_user.vehicles) == 1:
return redirect(url_for('create_service_for_vehicle', vid=current_user.vehicles[0].id))
form = SelectVehicleForm()
form.vehicle.choices = [(g.id, g.name) for g in current_user.vehicles]
if form.validate_on_submit():
return redirect(url_for('create_service_for_vehicle', vid=form.vehicle.data))
return render_template('selectVehicle.html', form=form)

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 980 B

After

Width:  |  Height:  |  Size: 980 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

95
app/static/main.css Normal file
View File

@@ -0,0 +1,95 @@
.markdown > h1 {
font-size: 14px;
font-weight: bold;
}
.markdown {
text-align: left
}
body {
padding-top: 50px;
}
.starter-template {
padding-top: 0px;
padding-bottom: 60px;
text-align: center;
}
th {
text-align: center;
padding-left: 10px;
padding-right: 10px;
}
td {
text-align: right;
padding-left: 10px;
padding-right: 10px;
}
.error {
color: #a94442;
}
.nav-pills > li > a {
border: 1px solid;
}
h1 {
margin-top: 0px;
}
h2 {
margin-top: 0px;
}
h3 {
margin-top: 0px;
text-align: center;
}
h3:before{
content:"― ";
}
h3:after{
content:" ―";
}
.tab-content > .active {
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
border-bottom: 1px solid #ddd;
padding: 1px;
padding-top: 15px;
}
.panel-body > p, .panel-body > ul {
text-align: left;
}
.alert {
margin-bottom:0px;
}
.topspace {
margin-top: 5px;
}
// for small devices
@media only screen
and (min-device-width : 320px)
and (max-device-width : 568px) {
h3:before{
content:"";
}
h3:after{
content:"";
}
#charts_tabs {
display:none;
}
#charts_tabs-content {
display:none;
}
}

View File

@@ -0,0 +1,74 @@
{% extends "layout.html" %}
{% block body %}
<h3>Account management for {{current_user.email}}</h3>
<div class="panel panel-default">
<div class="panel-heading">Password</div>
<div class="panel-body">
<a href="{{ url_for('security.change_password') }}" id="change_password" class="btn btn-primary " role="button">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Change
</a>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">Vehicles</div>
<div class="panel-body">
<a href="{{ url_for('create_vehicle') }}" id="create_vehicle" class="btn btn-primary " role="button">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> create
</a>
</div>
<table class="table table-striped table-bordered">
<tbody>
<tr>
<th>
Vehicle
</th>
<th>
Info
</th>
<th>
Actions
</th>
</tr>
{% for vehicle in current_user.vehicles %}
<tr>
<td>
{{ vehicle.name }}
</td>
<td>
{{ vehicle.pitstops | length }} pitstops<br />
{{ vehicle.services | length }} special expenses<br />
{{ vehicle.consumables | length }} consumables
</td>
<td>
<a href="{{ url_for('create_service_for_vehicle', vid=vehicle.id) }}" id="pitstop_{{loop.index}}" class="btn btn-primary " role="button">
<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span> add service
</a>
<a href="{{ url_for('select_consumable_for_new_pitstop', vid=vehicle.id) }}" id="pitstop_{{loop.index}}" class="btn btn-primary " role="button">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> add pitstop
</a>
<a href="{{ url_for('edit_vehicle', vid=vehicle.id) }}" id="edit_vehicle_{{loop.index}}" class="btn btn-primary " role="button">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
{% if current_user.vehicles | length > 1 %}
<a href="{{ url_for('delete_vehicle', vid=vehicle.id) }}" id="delete_vehicle_{{loop.index}}" class="btn btn-primary btn-warning " role="button">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> delete
</a>
{% else %}
&nbsp;
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel panel-default">
<div class="panel-heading">Account</div>
<div class="panel-body">
<a href="{{ url_for('delete_account') }}" id="delete_account" class="btn btn-primary " role="button">
<span class="glyphicon glyphicon-remove" aria-hidden="true"></span> Delete
</a>
</div>
</div>
{% endblock %}

60
app/templates/admin.html Normal file
View File

@@ -0,0 +1,60 @@
{% extends "layout.html" %}
{% block body %}
<h3>Admin</h3>
<div class="panel panel-default">
<div class="panel-heading">Users</div>
<div class="panel-body">
We have {{ users|length }} users so far:
<ul>
{% for user in users %}
<li>{{user.email}}</li>
{% endfor %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">Consumables</div>
<div class="panel-body">
<a href="{{ url_for('create_consumable') }}" id="create_consumable" class="btn btn-primary " role="button">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> create
</a>
</div>
<table class="table table-striped table-bordered">
<tbody>
<tr>
<th>
Name
</th>
<th>
Unit
</th>
<th>
Used by
</th>
<th>
Actions
</th>
</tr>
{% for consumable in consumables %}
<tr>
<td id="name_{{loop.index}}">{{ consumable.name }}</td>
<td id="unit_{{loop.index}}">{{ consumable.unit }}</td>
<td id="count_{{loop.index}}">{{ consumable.vehicles | length }} vehicles</td>
<td>
{% if not consumable.in_use %}
<a href="{{ url_for('delete_consumable', cid=consumable.id) }}" id="delete_consumable{{loop.index}}" class="btn btn-primary btn-warning " role="button">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> delete
</a>
{% endif %}
<a href="{{ url_for('edit_consumable', cid=consumable.id) }}" id="edit_consumable{{loop.index}}" class="btn btn-primary " role="button">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Create consumable</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.name) }}
{{ render_field_with_errors(form.unit) }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>New Pitstop for '{{ vehicle.name }}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.date) }}
<span id="{{form.date.id}}_help" class="help-block">
{{messages['date']}}
</span>
{{ render_field_with_errors(form.odometer) }}
<span id="{{form.odometer.id}}_help" class="help-block">
{{messages['odometer']}}
</span>
{{ render_field_with_errors(form.litres) }}
{{ render_field_with_errors(form.costs) }}
<span id="{{form.costs.id}}_help" class="help-block">
{{messages['costs']}}
</span>
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>New Service for '{{ vehicle.name }}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.date) }}
<span id="{{form.date.id}}_help" class="help-block">
{{messages['date']}}
</span>
{{ render_field_with_errors(form.odometer) }}
<span id="{{form.odometer.id}}_help" class="help-block">
{{messages['odometer']}}
</span>
{{ render_field_with_errors(form.costs) }}
<span id="{{form.costs.id}}_help" class="help-block">
{{messages['costs']}}
</span>
{{ render_field_with_errors(form.description) }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Create vehicle</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.name) }}
{{ render_field_with_errors(form.consumables) }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Delete account for '{{current_user.email}}'?</h3>
This cannot be undone!
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Delete consumable '{{consumable.name}}'?</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends 'layout.html' %}
{% block body %}
<div class='col-md-2' ></div>
<div class='col-md-8'>
<div class='panel panel-default'>
<div class='panel-body'>
<h3>Delete pitstop?</h3>
<table style='width: 100%' class="table table-striped table-bordered table-condensed">
<tr>
<th style='text-align:right'>Date of Pitstop</th>
<td style='text-align: left'>{{ pitstop.date }}</td>
</tr>
<tr>
<th style='text-align:right'>Odometer</th>
<td style='text-align: left'>{{ pitstop.odometer }} km</td>
</tr>
<tr>
<th style='text-align:right'>Litres</th>
<td style='text-align: left'>{{ pitstop.amount }} {{ pitstop.consumable.unit }}</td>
</tr>
<tr>
<th style='text-align:right'>Costs (overall)</th>
<td style='text-align: left'>
{% if pitstop.costs %}
{{pitstop.costs}}
{% else %}
--
{% endif %}
</td>
</tr>
</table>
<form class='form-horizontal' method='POST'>
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class='col-md-2' ></div>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends 'layout.html' %}
{% block body %}
<div class='col-md-2' ></div>
<div class='col-md-8'>
<div class='panel panel-default'>
<div class='panel-body'>
<h3>Delete service?</h3>
<table style='width: 100%' class="table table-striped table-bordered table-condensed">
<tr>
<th style='text-align:right'>Date of Pitstop</th>
<td style='text-align: left'>{{ service.date }}</td>
</tr>
<tr>
<th style='text-align:right'>Odometer</th>
<td style='text-align: left'>{{ service.odometer }} km</td>
</tr>
<tr>
<th style='text-align:right'>Description</th>
<td style='text-align: left' class="markdown">{{ service.description | markdown | safe}}</td>
</tr>
<tr>
<th style='text-align:right'>Costs (overall)</th>
<td style='text-align: left'>
{% if service.costs %}
{{service.costs}}
{% else %}
--
{% endif %}
</td>
</tr>
</table>
<form class='form-horizontal' method='POST'>
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class='col-md-2' ></div>
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Delete vehicle '{{vehicle.name}}'?</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Edit consumable</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.name) }}
{{ render_field_with_errors(form.unit) }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Edit Pitstop for '{{ vehicle.name }}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.date) }}
<span id="{{form.date.id}}_help" class="help-block">
{{messages['date']}}
</span>
{{ render_field_with_errors(form.odometer) }}
<span id="{{form.odometer.id}}_help" class="help-block">
{{messages['odometer']}}
</span>
{{ render_field_with_errors(form.litres) }}
{{ render_field_with_errors(form.costs) }}
<span id="{{form.costs.id}}_help" class="help-block">
{{messages['costs']}}
</span>
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Edit Pitstop for '{{ vehicle.name }}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.date) }}
<span id="{{form.date.id}}_help" class="help-block">
{{messages['date']}}
</span>
{{ render_field_with_errors(form.odometer) }}
<span id="{{form.odometer.id}}_help" class="help-block">
{{messages['odometer']}}
</span>
{{ render_field_with_errors(form.description) }}
{{ render_field_with_errors(form.costs) }}
<span id="{{form.costs.id}}_help" class="help-block">
{{messages['costs']}}
</span>
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Edit vehicle '{{vehicle.name}}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.name) }}
{{ render_field_with_errors(form.consumables) }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
{% endblock %}

34
app/templates/index.html Normal file
View File

@@ -0,0 +1,34 @@
{% extends "layout.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}
{% block body %}
<div class="row">
<div class="col-md-4">
{{ render_login_form() }}
</div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body" >
<h1>Join the pitstop community!</h1>
<p>There are already {{ data.users}} members with {{ data.vehicles }} vehicles who have logged {{ data.kilometers }}km in {{ data.pitstops }} pitstops. They fuelled</p>
<ul>
{% for key in data.consumables %}
{% set consumable = data.consumables[key] %}
<li>{{consumable.amount}}{{consumable.unit}} of {{consumable.name}}</li>
{% endfor %}
</ul>
<p>With pitstop community you can:</p>
<ul>
<li>manage multiple vehicles</li>
<li>track each pitstop</li>
<li>get statistics about the fuel consumption</li>
</ul>
<p><a href='{{ url_for('security.register') }}'>Register your account now</a> or <a href='{{ url_for('security.login') }}'>log into your account</a>.</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,33 +1,48 @@
{% macro navigation() -%}
{% if current_user.email %}
<li><a href='{{ url_for('select_vehicle_for_new_pitstop') }}'>Create Pitstop</a></li>
<li><a href='{{ url_for('get_statistics') }}'>Statistics</a></li>
<li><a href='{{ url_for('get_account_page') }}'>Account</a></li>
<li><a id='new_pitstop_link' href='{{ url_for('select_vehicle_for_new_pitstop') }}'>Create Pitstop</a></li>
<li><a id='new_service_link' href='{{ url_for('select_vehicle_for_new_service') }}'>Create Service</a></li>
<li><a id='statistics_limk' href='{{ url_for('get_statistics') }}'>Statistics</a></li>
<li><a id='account_link' href='{{ url_for('get_account_page') }}'>Account</a></li>
{% if current_user.has_role('admin') %}
<li><a href='{{ url_for('get_admin_page') }}'>Admin</a></li>
<li><a id='admin_link' href='{{ url_for('get_admin_page') }}'>Admin</a></li>
{% endif %}
<li><a href='{{ url_for('security.logout') }}'>Logout</a></li>
<li><a id='logout_link' href='{{ url_for('security.logout') }}'>Logout</a></li>
{% else %}
<li><a href='{{ url_for('security.login') }}'>Login</a></li>
<li><a href='{{ url_for('security.register') }}'>Register</a></li>
<li><a id='login_link' href='{{ url_for('security.login') }}'>Login</a></li>
<li><a id='register_link' href='{{ url_for('security.register') }}'>Register</a></li>
{% endif %}
{%- endmacro %}
{% macro render_field_with_errors(field) %}
<div class="form-group">
{% if field.type == 'SubmitField' %}
<div class="col-sm-12" style="align:center">
<div class="col-md-4" ></div>
<div class="col-sm-4" style="align:center">
<input id="{{ field.id }}" name="{{ field.id }}" class="btn btn-default" type="submit" value="{{ field.label.text }}">
</div>
<!--
<div class="col-sm-3" style="align:center">
<a class="btn btn-default" href="{{ g.data['back'] }}" role="button">Cancel</a>
</div>
-->
<div class="col-md-4" ></div>
{% else %}
<label class="col-sm-6 control-label">
{{ field.label }}
</label>
<div class="col-sm-2">
<div class="col-sm-6">
{% if field.type == 'SelectField' %}
<select id="{{ field.id }}" name="{{ field.id }}" class="form-control">
{% for choice in field.choices %}
<option value="{{ choice[0] }}">{{ choice[1] }}</option>
<option value="{{ choice[0] }}" {% if choice[0] == field.default %}selected="selected"{%endif%}>{{ choice[1] }}</option>
{% endfor %}
</select>
{% elif field.type == 'SelectMultipleField' %}
<select id="{{ field.id }}" name="{{ field.id }}" class="form-control" multiple="multiple">
{% for choice in field.choices %}
<option value="{{ choice[0] }}" {% if choice[0] in field.default %}selected="selected"{%endif%}>{{ choice[1] }}</option>
{% endfor %}
</select>
{% elif field.type == 'BooleanField' %}
@@ -42,6 +57,8 @@
<input class="form-control" type="number" id="{{ field.id }}" name="{{ field.id }}" value="{{ field.default|none_filter }}" step="1" aria-describedby="{{ field.id }}_help" />
{% elif field.type == 'DecimalField' %}
<input class="form-control" type="number" id="{{ field.id }}" name="{{ field.id }}" value="{{ field.default|none_filter }}" step="{{ 1 / 10 ** field.places}}" aria-describedby="{{ field.id }}_help" />
{% elif field.type == 'TextAreaField' %}
<textarea class="form-control" id="{{ field.id }}" name="{{ field.id }}">{{ field.default|none_filter }}</textarea>
{% else %}
{{ field(**kwargs)|safe }}
{% endif %}
@@ -54,36 +71,30 @@
{% endif %}
</div>
{% endif %}
</div>
{% endmacro %}
{% macro chartScript(divId, data, unit)%}
{% set hash = divId | md5 %}
data_{{ hash }} = [{% for stop in data %}{
"date": "{{stop.date}}",
"value": {{stop.value}}
}{% if not loop.last %},{%endif%}
{% endfor%}
]
var chart_{{ hash }} = createChart('{{divId}}', data_{{ hash }}, '{{unit}}');
function zoom_chart_{{ hash }}() {
chart_{{ hash }}.zoomToIndexes(
chart_{{ hash }}.dataProvider.length - 40,
chart_{{ hash }}.dataProvider.length - 1
);
}
chart_{{ hash }}.addListener("rendered", zoom_chart_{{ hash }});
zoom_chart_{{ hash }}()
{% macro render_login_form() %}
<div class="panel panel-default">
<div class="panel-body">
<h3>Login</h3>
<form class='form-horizontal' action="{{ url_for_security('login') }}" method="POST" name="login_user_form">
{{ login_user_form.hidden_tag() }}
{{ render_field_with_errors(login_user_form.email) }}
{{ render_field_with_errors(login_user_form.password) }}
{{ render_field_with_errors(login_user_form.remember) }}
{{ render_field(login_user_form.next) }}
{{ render_field_with_errors(login_user_form.submit) }}
{% if security.recoverable %}
<a href="{{ url_for_security('forgot_password') }}">Forgot password</a>
{% endif %}
</form>
</div>
</div>
{% endmacro %}
<!doctype html>
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7" lang=""> <![endif]-->
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8" lang=""> <![endif]-->
@@ -134,7 +145,7 @@
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{{ url_for('get_pit_stops') }}">refuel journal</a>
<a class="navbar-brand" href="{{ url_for('index') }}">refuel journal</a>
</div>
<div id="navbar" class="collapse navbar-collapse">
<ul class="nav navbar-nav">
@@ -143,13 +154,36 @@
</div><!--/.nav-collapse -->
</div>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="row topspace">
<div class="col-md-4" ></div>
<div class="col-md-4">
<div class="alert alert-{{category}} alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
{{ message }}
</div>
</div>
<div class="col-md-4" ></div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="container topspace">
<div class="starter-template">
{% block body %}
{% endblock %}
</div>
</div>
{#
<nav class="navbar navbar-inverse navbar-fixed-bottom">
<div class="container">
<div class="navbar-footer">
<a class="navbar-brand" href="">Imprint</a>
</div>
</div>
</nav>
#}
</body>
</html>

135
app/templates/pitstops.html Normal file
View File

@@ -0,0 +1,135 @@
{% extends "layout.html" %}
{% macro pitstop(field, vindex, loop) -%}
<div class="panel panel-default">
<div class="panel-body">
<div style="text-align: left; font-size: 20px;">
<span class="glyphicon glyphicon-filter" aria-hidden="true" style="border: 1px solid black; padding: 5px 5px 3px; border-radius: 5px;"></span>
</div>
<table class="table table-striped table-bordered table-condensed">
<tr>
<th>Date</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_date">{{field.date}}</td>
</tr>
<tr>
<th>Odometer</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_odo">{{field.odometer}} km</td>
</tr>
<tr>
<th>{{ field.consumable.name }}</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_anmount">{{field.amount}} {{ field.consumable.unit }}</td>
</tr>
<tr>
<th>Costs</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_cost">
{% if field.costs %}
{{field.costs}} €
{% else %}
-- €
{% endif %}
</td>
</tr>
</table>
{% if loop.first %}
<a id="vehicle_{{vindex}}_edit_pitstop_{{loop.index}}" href="{{ url_for('edit_pit_stop_form', pid=field.id) }}" class="btn btn-primary">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
<a id="vehicle_{{vindex}}_delete_pitstop_{{loop.index}}" href="{{ url_for('delete_pit_stop_form', pid=field.id) }}" class="btn btn-primary btn-warning ">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> delete
</a>
{% endif %}
</div>
</div>
{%- endmacro %}
{% macro service(field, vindex, loop) -%}
<div class="panel panel-default">
<div class="panel-body">
<div style="text-align: left; font-size: 20px;">
<span class="glyphicon glyphicon-wrench" aria-hidden="true" style="border: 1px solid black; padding: 5px 5px 3px; border-radius: 5px;"></span>
</div>
<table class="table table-striped table-bordered table-condensed">
<tr>
<th>Date</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_date">{{field.date}}</td>
</tr>
<tr>
<th>Odometer</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_desc">{{field.odometer}} km</td>
</tr>
<tr>
<th>Description</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_anmount" class="markdown">{{field.description | markdown | safe}}</td>
</tr>
<tr>
<th>Costs</th>
<td id="vehicle_{{vindex}}_pitstop_{{loop.index}}_cost">
{% if field.costs %}
{{field.costs}} €
{% else %}
-- €
{% endif %}
</td>
</tr>
</table>
{% if loop.first %}
<a id="vehicle_{{vindex}}_edit_pitstop_{{loop.index}}" href="{{ url_for('edit_service_form', sid=field.id) }}" class="btn btn-primary">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
<a id="vehicle_{{vindex}}_delete_pitstop_{{loop.index}}" href="{{ url_for('delete_service_form', sid=field.id) }}" class="btn btn-primary btn-warning ">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> delete
</a>
{% endif %}
</div>
</div>
{%- endmacro %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<ul id="tabs" class="nav nav-tabs" data-tabs="tabs">
{% for vehicle in user.vehicles %}
<li {% if loop.first %}class="active" {%endif %}>
<a href="#v{{vehicle.id}}" id="i{{vehicle.id}}" data-toggle="tab">
{{ vehicle.name }}
</a>
</li>
{% endfor %}
</ul>
<div id="my-tab-content" class="tab-content">
{% for vehicle in user.vehicles %}
{% set vehicleloop = loop %}
<div class="tab-pane {% if loop.first %}active{% endif %}" id="v{{vehicle.id}}">
<h3>{{vehicle.name}}</h3>
{% if vehicle.data %}
{% for data in vehicle.data|reverse %}
{% if 'Pitstop' in data.__class__.__name__ %}
{{ pitstop(data, vehicleloop.index, loop) }}
{% endif %}
{% if 'Service' in data.__class__.__name__ %}
{{ service(data, vehicleloop.index, loop) }}
{% endif %}
{% endfor %}
{% else %}
<div class="alert alert-warning" role="alert">
not enough data: <a href="{{ url_for('select_consumable_for_new_pitstop', vid=vehicle.id) }}">log a pitstop</a>?
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<div class="col-md-2" ></div>
<script type="text/javascript">
jQuery(document).ready(function ($) {
$('#tabs').tab();
if(window.location.hash != "") {
$('a[href="' + window.location.hash + '"]').click()
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "layout.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Change password</h3>
<form class='form-horizontal' action="{{ url_for_security('change_password') }}" method="POST" name="change_password_form">
{{ change_password_form.hidden_tag() }}
{{ render_field_with_errors(change_password_form.password) }}
{{ render_field_with_errors(change_password_form.new_password) }}
{{ render_field_with_errors(change_password_form.new_password_confirm) }}
{{ render_field_with_errors(change_password_form.submit) }}
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Reset password</h3>
<form class='form-horizontal' action="{{ url_for_security('forgot_password') }}" method="POST" name="forgot_password_form">
{{ forgot_password_form.hidden_tag() }}
{{ render_field_with_errors(forgot_password_form.email) }}
{{ render_field_with_errors(forgot_password_form.submit) }}
</form>
</div>
</div>
<div class="col-md-2" ></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "layout.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}
{% block body %}
<div class="row">
<div class="col-md-2" ></div>
<div class="col-md-8">
{{ render_login_form() }}
</div>
<div class="col-md-2" ></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "layout.html" %}
{% from "security/_macros.html" import render_field_with_errors, render_field %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Register</h3>
<form class='form-horizontal' action="{{ url_for_security('register') }}" method="POST" name="register_user_form">
{{ register_user_form.hidden_tag() }}
{{ render_field_with_errors(register_user_form.email) }}
{{ render_field_with_errors(register_user_form.password) }}
{% if register_user_form.password_confirm %}
{{ render_field_with_errors(register_user_form.password_confirm) }}
{% endif %}
{{ render_field_with_errors(register_user_form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Select Consumable for '{{ vehicle.name }}'</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.consumable) }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block body %}
<div class="col-md-2" ></div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-body">
<h3>Select Vehicle</h3>
<form class='form-horizontal' method="POST">
{{ form.hidden_tag() }}
{{ render_field_with_errors(form.vehicle) }}
{{ render_field_with_errors(form.submit) }}
</form>
</div>
</div>
</div>
<div class="col-md-2" ></div>
{% endblock %}

View File

@@ -0,0 +1,175 @@
{% extends "layout.html" %}
{% macro chartScript(divId, data, unit)%}
{% set hash = divId | md5 %}
data_{{ hash }} = [{% for stop in data %}{
"date": "{{stop.date}}",
"value": {{stop.value}}
}{% if not loop.last %},{%endif%}
{% endfor%}
]
var chart_{{ hash }} = createChart('{{divId}}', data_{{ hash }}, '{{unit}}');
function zoom_chart_{{ hash }}() {
chart_{{ hash }}.zoomToIndexes(
chart_{{ hash }}.dataProvider.length - 40,
chart_{{ hash }}.dataProvider.length - 1
);
}
chart_{{ hash }}.addListener("rendered", zoom_chart_{{ hash }});
zoom_chart_{{ hash }}()
{% endmacro %}
{% macro nav_tab(id, text, active) %}
{#
#}
<li class="{% if active %}active{% endif %}">
<a href="#ref_{{id}}" id="id_{{id}}" data-toggle="tab" >
{{ text }}
</a>
</li>
{% endmacro %}
{% macro tab_pane(id, content, active) %}
<div class="tab-pane {% if active %}active{% endif %}" id="ref_{{ id }}">
{{ content }}
</div>
{% endmacro %}
{% macro chart(data, baseId, unit, link) %}
{% if data|length > 0 %}
{% set chartID = 'chart_' + baseId %}
<div id="{{ chartID }}" style="width:100%; height:500px;"></div>
<script type="text/javascript">
{{ chartScript(chartID, data, unit) }}
</script>
{% else %}
<div class="alert alert-warning" role="alert">
not enough data: <a href="{{ link }}">log a pitstop</a>?
</div>
{% endif %}
{% endmacro %}
{% macro print_consumable_table(consumable) %}
<table class="table table-striped table-bordered table-condensed">
<tr>
<th>Average Distance:</th>
<td>{{ consumable.average_distance | round(2) }} km</td>
</tr>
<tr>
<th>Amount fuelled:</th>
<td>{{ consumable.overall_amount | round(2) }} {{ consumable.unit }}</td>
</tr>
<tr>
<th>Average Amount fuelled:</th>
<td>{{ consumable.average_amount_fuelled | round(2) }} {{ consumable.unit }}</td>
</tr>
<tr>
<th>Average Amount used:</th>
<td>{{ consumable.average_amount_used | round(2) }} {{ consumable.unit }}/100km</td>
</tr>
</table>
{% endmacro %}
{% macro print_vehicle_table(vehicle) %}
<h3>{{vehicle.name}}</h3>
<div class="table-responsive">
<table class="table table-striped table-bordered table-condensed">
<tr>
<th>Logged Distance:</th>
<td>{{ vehicle.overall_distance | round(2) }} km</td>
</tr>
<tr>
<th>Logged Costs:</th>
<td>{{ vehicle.overall_costs | round(2) }} €</td>
</tr>
</table>
</div>
{% endmacro %}
{% macro tab_script(id) %}
<script type="text/javascript">
jQuery(document).ready(function ($) {
$('#{{id}}').tab();
if(window.location.hash != "") {
$('a[href="' + window.location.hash + '"]').click()
}
});
</script>
{% endmacro %}
{% block body %}
<div id="content">
<ul id="vehicle_tabs" class="nav nav-tabs" data-tabs="tabs">
{% for vehicle in data %}
{{ nav_tab(vehicle.id, vehicle.name, loop.first) }}
{% endfor %}
</ul>
<div id="vehicle_content" class="tab-content ">
{% for vehicle in data %}
<div class="tab-pane {% if loop.first %}active{% endif %}" id="ref_{{vehicle.id}}">
{{ print_vehicle_table(vehicle) }}
<ul id="vehicle_{{vehicle.id}}_tabs" class="nav nav-tabs" data-tabs="tabs">
{{ nav_tab(vehicle.id|string + '_odometer', 'Odometer', true) }}
{% for consumable in vehicle.consumables %}
{{ nav_tab(vehicle.id|string + '_' + consumable.id|string, consumable.name, false) }}
{% endfor %}
</ul>
<div id="vehicle_{{vehicle.id}}_content" class="tab-content ">
{{ tab_pane(
vehicle.id|string + '_odometer',
chart(
vehicle.odometers,
'ref_' + vehicle.id|string + '_odometer',
'km',
url_for('select_consumable_for_new_pitstop', vid=vehicle.id)
),
true
)
}}
{% for consumable in vehicle.consumables %}
<div class="tab-pane" id="ref_{{vehicle.id}}_{{consumable.id}}">
{{ print_consumable_table(consumable) }}
<ul id="consumable_{{vehicle.id}}_{{consumable.id}}_tabs" class="nav nav-tabs" data-tabs="tabs">
{{ nav_tab(vehicle.id|string + '_' + consumable.id|string + '_consumption', 'Consumption', true) }}
{{ nav_tab(vehicle.id|string + '_' + consumable.id|string + '_amount', 'Amount', false) }}
</ul>
<div id="consumable_{{vehicle.id}}_{{consumable.id}}_content" class="tab-content ">
{{ tab_pane(
vehicle.id|string + '_' + consumable.id|string + '_consumption',
chart(
consumable.average_amount,
'ref_' + vehicle.id|string + '_' + consumable.id|string + '_consumption',
consumable.unit + '/100km',
url_for('create_pit_stop_form', vid=vehicle.id, cid=consumable.id)
),
true
)
}}
{{ tab_pane(
vehicle.id|string + '_' + consumable.id|string + '_amount',
chart(
consumable.amounts,
'ref_' + vehicle.id|string + '_' + consumable.id|string + '_amount',
consumable.unit,
url_for('create_pit_stop_form', vid=vehicle.id, cid=consumable.id)
),
false
)
}}
</div>
{{ tab_script('vehicle_' + vehicle.id|string + '_' + consumable.id|string + '_tabs') }}
</div>
{% endfor %}
</div>
{{ tab_script('vehicle_' + vehicle.id|string + '_tabs') }}
</div>
{% endfor %}
</div>
{{ tab_script('vehicle_tabs') }}
</div>
{% endblock %}

191
app/tools.py Normal file
View File

@@ -0,0 +1,191 @@
import logging
from datetime import date
from .entities import Pitstop
class ConsumableStats:
def __init__(self, vehicle, consumable):
self.name = consumable.name
self.id = consumable.id
self.unit = consumable.unit
self.overall_amount = 0
self.average_distance = 0
self.average_amount_fuelled = 0
self.average_amount_used = 0
self.average_amount = []
self.amounts = []
pitstops = [stop for stop in vehicle.pitstops if stop.consumable_id == consumable.id]
pitstop_count = len(pitstops)
if pitstop_count > 0:
for pitstop in pitstops:
self.overall_amount += pitstop.amount
self.amounts.append(StatsEvent(pitstop.date, pitstop.amount))
self.average_amount_fuelled = self.overall_amount / pitstop_count
if pitstop_count > 1:
overall_distance = vehicle.pitstops[-1].odometer - vehicle.pitstops[0].odometer
self.average_distance = overall_distance / (pitstop_count - 1)
self.average_amount_used = 100 * (self.overall_amount - pitstops[0].amount) / overall_distance
for index in range(1, pitstop_count):
last_ps = pitstops[index - 1]
current_ps = pitstops[index]
self.average_amount.append(
StatsEvent(
current_ps.date,
round(100 * current_ps.amount/(current_ps.odometer - last_ps.odometer), 2)))
class VehicleStats:
def __init__(self, vehicle):
self.name = vehicle.name
self.id = vehicle.id
self.overall_distance = 0
self.overall_costs = 0
self.consumables = []
self.odometers = []
for consumable in vehicle.consumables:
self.consumables.append(ConsumableStats(vehicle, consumable))
events = get_event_line_for_vehicle(vehicle)
pitstop_count = len(events)
if pitstop_count > 0:
for pitstop in events:
self.odometers.append(StatsEvent(pitstop.date, pitstop.odometer))
if pitstop.costs is not None:
self.overall_costs += pitstop.costs
if pitstop_count > 1:
self.overall_distance = events[-1].odometer - events[0].odometer
class StatsEvent:
def __init__(self, date, value):
self.date = date
self.value = value
def db_log_add(entity):
logging.info('db_add: %s' % str(entity))
def db_log_delete(entity):
logging.info('db_delete: %s' % str(entity))
def db_log_update(entity):
logging.info('db_update: %s' % str(entity))
def check_vehicle_name_is_unique(current_user, name_field):
"""
Checks if the vehicle name given in the name_field is unique for the vehicles of the current user. An error is added
to the field it the name is not unique.
:param current_user: the user currently logged in
:param name_field: the form field to enter the name to
:return: True if the name is unique, False otherwise.
"""
vehicle_name = name_field.data
for vehicle in current_user.vehicles:
if vehicle.name == vehicle_name:
name_field.default = vehicle_name
name_field.errors.append('Vehicle "%s" already exists.' % vehicle_name)
return False
return True
def get_latest_pitstop_for_vehicle(vehicle_id):
"""
return the latest pit stop for the vehicle with the given id.
:param vehicle_id: the id of the vehicle
:return: the latest pitstop or None if no pitstop exists
"""
latest_pitstop = Pitstop.query\
.filter(Pitstop.vehicle_id == vehicle_id)\
.order_by(Pitstop.id.desc())\
.first()
return latest_pitstop
def get_latest_pitstop_for_vehicle_and_consumable(vehicle_id, consumable_id):
"""
return the latest pit stop for the vehicle and consumable with the given ids.
:param vehicle_id: the id of the vehicle
:param consumable_id: the id of the consumable
:return: the latest pitstop or None if no pitstop exists
"""
latest_pitstop_consumable = Pitstop.query\
.filter(Pitstop.vehicle_id == vehicle_id)\
.filter(Pitstop.consumable_id == consumable_id)\
.order_by(Pitstop.id.desc())\
.first()
return latest_pitstop_consumable
def compute_lower_limits_for_new_pitstop(latest_pitstop, last_pitstop_consumable, consumable_id):
"""
This function figures out the lower limits for date and odometer of a new pitstop.
:param latest_pitstop:
:param last_pitstop_consumable:
:param consumable_id:
:return:
"""
odometer = 0
date_of_pitstop = date(1970, 1, 1)
amount = 0
costs = 0
if latest_pitstop is not None:
odometer = latest_pitstop.odometer
date_of_pitstop = latest_pitstop.date
if last_pitstop_consumable is not None:
amount = last_pitstop_consumable.amount
costs = last_pitstop_consumable.costs
return Pitstop(odometer, amount, date_of_pitstop, costs, consumable_id)
# if latest_pitstop is not None:
# if last_pitstop_consumable is not None and last_pitstop_consumable != latest_pitstop:
# if latest_pitstop.id > last_pitstop_consumable.id:
# return Pitstop(latest_pitstop.odometer,
# last_pitstop_consumable.overall_amount,
# latest_pitstop.date,
# last_pitstop_consumable.costs,
# consumable_id)
# else:
# return Pitstop(last_pitstop_consumable.odometer,
# last_pitstop_consumable.overall_amount,
# last_pitstop_consumable.date,
# last_pitstop_consumable.costs,
# consumable_id)
# else:
# # either only one pitstop exists or both are the same
# litres = 0
# costs = 0
# if latest_pitstop.consumable_id == last_pitstop_consumable.consumable_id:
# litres = latest_pitstop.overall_amount
# costs = latest_pitstop.costs
# return Pitstop(latest_pitstop.odometer, litres, latest_pitstop.date, costs, consumable_id)
# else:
# # No existing pitstop at all: insert fake data
# return Pitstop(0, 0, date(1970, 1, 1), 0, None)
def pitstop_service_key(x):
return x.odometer, x.date
def get_event_line_for_vehicle(vehicle):
data = []
for pitstop in vehicle.pitstops:
data.append(pitstop)
for service in vehicle.services:
data.append(service)
data.sort(key=pitstop_service_key)
return data

16
compose_config/config.py Normal file
View File

@@ -0,0 +1,16 @@
import os
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:%s@database/pitstops' % (os.environ['DATABASE_ENV_MYSQL_ROOT_PASSWORD'])
#SQLALCHEMY_DATABASE_URI = 'sqlite:////data/rollerverbrauch.db'
MAIL_SERVER = ''
MAIL_PORT= 25
MAIL_USE_TLS = True
MAIL_USE_SSL = False
MAIL_USERNAME = ''
MAIL_PASSWORD = ''
SECURITY_EMAIL_SENDER = ''
SECURITY_PASSWORD_SALT = 'SecretSalt'
SECRET_KEY = 'SecretKey'
SECURITY_SEND_REGISTER_EMAIL = False

47
config.py Normal file
View File

@@ -0,0 +1,47 @@
import os
basedir = os.path.abspath(os.path.dirname(__file__))
class Config:
SECURITY_PASSWORD_HASH = 'pbkdf2_sha512'
SECURITY_REGISTERABLE = True
SECURITY_CHANGEABLE = True
SECURITY_RECOVERABLE = True
SECURITY_PASSWORD_SALT = os.environ.get('SECURITY_PASSWORD_SALT') or 'SOME SECRET STRING'
SECRET_KEY = os.environ.get('SECRET_KEY') or 'SOME SECRET STRING'
SECURITY_EMAIL_SENDER = 'no-reply@lusiardi.de'
SQLALCHEMY_TRACK_MODIFICATIONS = False
MAIL_SERVER = 'mail.nerd2nerd.org'
MAIL_PORT = 25
MAIL_USE_TLS = True
MAIL_USE_SSL = False
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
ADMIN_MAIL = 'joachim@lusiardi.de'
@staticmethod
def init_app(app):
pass
class DevelopmentConfig(Config):
SECURITY_SEND_REGISTER_EMAIL = False
DEBUG = True
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:%s@database/pitstops' % (os.environ.get('DATABASE_ENV_MYSQL_ROOT_PASSWORD'))
class TestingConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data_testing.sqlite')
class ProductionConfig(Config):
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data.sqlite')
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS `consumable` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`unit` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=latin1;
INSERT INTO `consumable`(`id`, `name`, `unit`) VALUES (1, 'Super E5', 'l');
CREATE TABLE IF NOT EXISTS `vehicles_consumables` (
`vehicle_id` int(11) DEFAULT NULL,
`consumable_id` int(11) DEFAULT NULL,
KEY `vehicle_id` (`vehicle_id`),
KEY `consumable_id` (`consumable_id`),
CONSTRAINT `vehicles_consumables_ibfk_1` FOREIGN KEY (`vehicle_id`) REFERENCES `vehicle` (`id`),
CONSTRAINT `vehicles_consumables_ibfk_2` FOREIGN KEY (`consumable_id`) REFERENCES `consumable` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
ALTER TABLE `pitstop` ADD COLUMN `costs` decimal(5,2) DEFAULT NULL;
ALTER TABLE `pitstop` CHANGE `litres` `amount` decimal(5,2) DEFAULT NULL;
ALTER TABLE `pitstop` ADD COLUMN `consumable_id` int(11);
ALTER TABLE `pitstop` ADD FOREIGN KEY (`consumable_id`) REFERENCES `consumable` (`id`);
UPDATE `pitstop` set `consumable_id`=1;
INSERT INTO `vehicles_consumables`(`vehicle_id`, `consumable_id`) SELECT `id`, 1 FROM `vehicle`;

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS `service` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`date` date DEFAULT NULL,
`odometer` int(11) DEFAULT NULL,
`vehicle_id` int(11) DEFAULT NULL,
`costs` decimal(10,2) DEFAULT NULL,
`description` varchar(4096) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `vehicle_id` (`vehicle_id`),
CONSTRAINT `service_ibfk_1` FOREIGN KEY (`vehicle_id`) REFERENCES `vehicle` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=latin1;

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
version: '2'
services:
rollerverbrauch:
build: .
environment:
- config=/config/config.py
- DATABASE_ENV_MYSQL_ROOT_PASSWORD=foobar123
- FLASK_CONFIG=testing
ports:
- 127.0.0.1:5000:5000

View File

@@ -1,5 +1,5 @@
import os
from rollerverbrauch import app
from app import app
import logging

View File

@@ -3,3 +3,4 @@ Flask-SQLAlchemy
Flask-Security
Flask-WTF
PyMySQL
markdown

12
tests/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM debian8_python3
COPY app/requirements.txt /requirements.txt
RUN pip3 install -r /requirements.txt; \
mkdir /data
ADD app /app
VOLUME ["/data"]
VOLUME ["/app/config]
EXPOSE 5000
ENTRYPOINT python3 /app/main.py

137
tests/app/main.py Normal file
View File

@@ -0,0 +1,137 @@
import unittest
import inspect
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions
def wait_net_service(server, port, timeout=None):
""" Wait for network service to appear
@param timeout: in seconds, if None or 0 wait forever
@return: True of False, if timeout is None may return only True or
throw unhandled network exception
"""
import socket
import errno
s = socket.socket()
if timeout:
from time import time as now
# time module is needed to calc timeout shared between two exceptions
end = now() + timeout
while True:
try:
if timeout:
next_timeout = end - now()
if next_timeout < 0:
return False
else:
s.settimeout(next_timeout)
s.connect((server, port))
except socket.timeout as err:
# this exception occurs only if timeout is set
if timeout:
return False
except socket.error as err:
# catch timeout exception from underlying network library
# this one is different from socket.timeout
if type(err.args) != tuple or err[0] != errno.ETIMEDOUT:
raise
else:
s.close()
return True
class BaseTestCase(unittest.TestCase):
def setUp(self):
self.screen_shot_counter = 0
self.driver = webdriver.Remote(
command_executor='http://selenium:4444/wd/hub',
desired_capabilities=DesiredCapabilities.FIREFOX)
self.driver.get("http://rollerverbrauch:5000")
WebDriverWait(self.driver, 10).until(expected_conditions.presence_of_element_located((By.ID, "email")))
def create_screenshot(self):
self.driver.get_screenshot_as_file(
'/results/%s_%s_%s.png' % (self.__class__.__name__, inspect.stack()[1][3], str(self.screen_shot_counter)))
self.screen_shot_counter += 1
def tearDown(self):
self.driver.close()
class RegisterCheck(BaseTestCase):
def setUp(self):
self.screen_shot_counter = 0
self.driver = webdriver.Remote(
command_executor='http://selenium:4444/wd/hub',
desired_capabilities=DesiredCapabilities.FIREFOX)
self.driver.get("http://rollerverbrauch:5000")
WebDriverWait(self.driver, 10).until(expected_conditions.presence_of_element_located((By.ID, "email")))
def test_page_loads(self):
self.driver.get("http://rollerverbrauch:5000")
self.create_screenshot()
self.assertIn("refuel", self.driver.title, "Title must contain reload")
def test_can_register(self):
self.driver.get("http://rollerverbrauch:5000")
self.driver.find_element_by_partial_link_text('Register').click()
self.create_screenshot()
WebDriverWait(self.driver, 10).until(expected_conditions.presence_of_element_located((By.ID, "submit")))
self.create_screenshot()
self.driver.find_element_by_id('email').send_keys('test@test.com')
self.driver.find_element_by_id('password').send_keys('test123')
self.driver.find_element_by_id('password_confirm').send_keys('test123')
self.create_screenshot()
self.driver.find_element_by_id('submit').click()
WebDriverWait(self.driver, 10).until(expected_conditions.presence_of_element_located((By.ID, "i1")))
self.create_screenshot()
def test_register_must_repeat_pwd(self):
self.driver.get("http://rollerverbrauch:5000")
self.driver.find_element_by_partial_link_text('Register').click()
self.create_screenshot()
WebDriverWait(self.driver, 10).until(expected_conditions.presence_of_element_located((By.ID, "submit")))
self.create_screenshot()
self.driver.find_element_by_id('email').send_keys('test1@test.com')
self.driver.find_element_by_id('password').send_keys('test123')
self.create_screenshot()
self.driver.find_element_by_id('submit').click()
self.create_screenshot()
error = self.driver.find_elements_by_class_name('error')
self.assertIsNotNone(error[0], 'we expect an error')
self.assertIn('Passwords do not match', error[0].text, 'wrong error message')
def test_register_must_be_equal_pwd(self):
self.driver.get("http://rollerverbrauch:5000")
self.driver.find_element_by_partial_link_text('Register').click()
self.create_screenshot()
WebDriverWait(self.driver, 10).until(expected_conditions.presence_of_element_located((By.ID, "submit")))
self.create_screenshot()
self.driver.find_element_by_id('email').send_keys('test1@test.com')
self.driver.find_element_by_id('password').send_keys('test123')
self.driver.find_element_by_id('password_confirm').send_keys('test1234')
self.create_screenshot()
self.driver.find_element_by_id('submit').click()
self.create_screenshot()
error = self.driver.find_elements_by_class_name('error')
self.assertIsNotNone(error[0], 'we expect an error')
self.assertIn('Passwords do not match', error[0].text, 'wrong error message')
class LoginCheck(BaseTestCase):
def can_login(self):
pass
if __name__ == "__main__":
wait_net_service('selenium', 4444)
unittest.main()

View File

@@ -0,0 +1 @@
selenium

View File

@@ -0,0 +1,16 @@
import os
#SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:%s@database/pitstops' % (os.environ['DATABASE_ENV_MYSQL_ROOT_PASSWORD'])
SQLALCHEMY_DATABASE_URI = 'sqlite:////data/rollerverbrauch.db'
MAIL_SERVER = ''
MAIL_PORT= 25
MAIL_USE_TLS = True
MAIL_USE_SSL = False
MAIL_USERNAME = ''
MAIL_PASSWORD = ''
SECURITY_EMAIL_SENDER = ''
SECURITY_PASSWORD_SALT = 'SecretSalt'
SECRET_KEY = 'SecretKey'
SECURITY_SEND_REGISTER_EMAIL = False

22
tests/docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
version: '2'
services:
tests:
build: .
depends_on:
- selenium
volumes:
- ./results:/results
selenium:
image: selenium/standalone-firefox
depends_on:
- rollerverbrauch
rollerverbrauch:
build: ..
volumes:
- ./compose_config/:/config
environment:
- config=/config/config.py
- DATABASE_ENV_MYSQL_ROOT_PASSWORD=foobar123
ports:
- 5000

7
tests/run_test.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
rm -f results/*
docker-compose build
docker-compose up --abort-on-container-exit
docker-compose down
docker-compose rm --all