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
This commit is contained in:
Joachim Lusiardi 2016-07-16 19:29:25 +02:00
commit fdb8993ac3
20 changed files with 877 additions and 355 deletions

View File

@ -26,8 +26,7 @@ reloaded automatically. The sqlite file will be stored in *tmp* so it
can be inspected with tools like *sqlite3*. The switch *DEBUG* enables can be inspected with tools like *sqlite3*. The switch *DEBUG* enables
debugging during development. 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 -e config=../config/config.py --link pitstops_db:database -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 ## 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 -d -v /data/pitstops/:/data -v /configs/pitstops/:/app/config -p 80:5000 rollerverbrauch`

View File

@ -12,6 +12,8 @@ from flask_security.forms import LoginForm
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
import os import os
from config import config from config import config
from sqlalchemy.exc import IntegrityError
from flask.ext.security.forms import LoginForm
from .forms import \ from .forms import \
CreatePitstopForm, \ CreatePitstopForm, \
@ -20,8 +22,12 @@ from .forms import \
SelectVehicleForm, \ SelectVehicleForm, \
DeleteAccountForm, \ DeleteAccountForm, \
DeletePitStopForm, \ DeletePitStopForm, \
EditPitstopForm EditPitstopForm, \
from .tools import * CreateConsumableForm, \
EditConsumableForm, \
DeletConsumableForm, \
SelectConsumableForm
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(config[os.getenv('FLASK_CONFIG') or 'default']) app.config.from_object(config[os.getenv('FLASK_CONFIG') or 'default'])
@ -33,10 +39,12 @@ from .entities import \
User, \ User, \
Role, \ Role, \
Pitstop, \ Pitstop, \
Vehicle Vehicle, \
from .filters import * Consumable
#import rollerverbrauch.tools as tools
# required to activate the filters # required to activate the filters
from .filters import *
from .tools import *
user_datastore = SQLAlchemyUserDatastore(db, User, Role) user_datastore = SQLAlchemyUserDatastore(db, User, Role)
@ -82,12 +90,12 @@ def index():
kilometers = 0 kilometers = 0
for vehicle in vehicles: for vehicle in vehicles:
stats = tools.VehicleStats(vehicle) stats = tools.VehicleStats(vehicle)
litres += stats.overall_litres #litres += stats.overall_litres
kilometers += stats.overall_distance kilometers += stats.overall_distance
vehicle_count = len(vehicles) vehicle_count = len(vehicles)
pitstop_count = len(Pitstop.query.all()) pitstop_count = len(Pitstop.query.all())
data = { data = {
'users':user_count, 'users': user_count,
'vehicles': vehicle_count, 'vehicles': vehicle_count,
'pitstops': pitstop_count, 'pitstops': pitstop_count,
'litres': litres, 'litres': litres,
@ -96,26 +104,47 @@ def index():
return render_template('index.html', login_user_form=LoginForm(), data=data) return render_template('index.html', login_user_form=LoginForm(), data=data)
@app.route('/account/edit_vehicle/<int:vid>', methods=['GET', 'POST']) @app.route('/account/vehicle/edit/<int:vid>', methods=['GET', 'POST'])
@login_required @login_required
def edit_vehicle(vid): def edit_vehicle(vid):
vehicle = Vehicle.query.filter(Vehicle.id == vid).first() 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 = 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(): if form.validate_on_submit():
if not tools.check_vehicle_name_is_unique(current_user, form.name):
return render_template('editVehicleForm.html', form=form)
vehicle.name = form.name.data vehicle.name = form.name.data
db.session.commit() # we cannot delete consumables where there are pitstops for => report error
tools.db_log_update(vehicle) 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()
tools.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')) return redirect(url_for('get_account_page'))
form.name.default = vehicle.name form.name.default = vehicle.name
form.process() form.process()
return render_template('editVehicleForm.html', form=form) return render_template('editVehicleForm.html', form=form, vehicle=vehicle)
@app.route('/account/delete_vehicle/<int:vid>', methods=['GET', 'POST']) @app.route('/account/vehicle/delete/<int:vid>', methods=['GET', 'POST'])
@login_required @login_required
def delete_vehicle(vid): def delete_vehicle(vid):
vehicle = Vehicle.query.filter(Vehicle.id == vid).first() vehicle = Vehicle.query.filter(Vehicle.id == vid).first()
@ -124,6 +153,9 @@ def delete_vehicle(vid):
if vehicle not in current_user.vehicles: if vehicle not in current_user.vehicles:
return redirect(url_for('get_account_page')) return redirect(url_for('get_account_page'))
if len(current_user.vehicles) == 1:
return redirect(url_for('get_account_page'))
form = DeleteVehicleForm() form = DeleteVehicleForm()
if form.validate_on_submit(): if form.validate_on_submit():
@ -135,75 +167,124 @@ def delete_vehicle(vid):
return render_template('deleteVehicleForm.html', form=form, vehicle=vehicle) return render_template('deleteVehicleForm.html', form=form, vehicle=vehicle)
@app.route('/account/create_vehicle', methods=['GET', 'POST']) @app.route('/account/vehicle/create', methods=['GET', 'POST'])
@login_required @login_required
def create_vehicle(): def create_vehicle():
form = EditVehicleForm() 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
if form.validate_on_submit(): if form.validate_on_submit():
vehicle_name = form.name.data if len(form.consumables.data) == 0:
if not tools.check_vehicle_name_is_unique(current_user, form.name): form.consumables.errors.append('At least one consumable must be selected.')
return render_template('createVehicleForm.html', form=form) return render_template('createVehicleForm.html', form=form)
vehicle_name = form.name.data
new_vehicle = Vehicle(vehicle_name) 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) db.session.add(new_vehicle)
current_user.vehicles.append(new_vehicle) current_user.vehicles.append(new_vehicle)
db.session.commit() try:
tools.db_log_add(new_vehicle) db.session.commit()
tools.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 redirect(url_for('get_account_page'))
return render_template('createVehicleForm.html', form=form) return render_template('createVehicleForm.html', form=form)
@app.route('/pitstops/select_vehicle', methods=['GET', 'POST']) @app.route('/pitstops/vehicle/select', methods=['GET', 'POST'])
@login_required @login_required
def select_vehicle_for_new_pitstop(): 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 = SelectVehicleForm()
form.vehicle.choices = [(g.id, g.name) for g in current_user.vehicles] form.vehicle.choices = [(g.id, g.name) for g in current_user.vehicles]
if form.validate_on_submit(): if form.validate_on_submit():
vehicle = Vehicle.query.filter(Vehicle.id == form.vehicle.data).first() return redirect(url_for('select_consumable_for_new_pitstop', vid=form.vehicle.data))
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('selectVehicle.html', form=form)
return render_template('selectVehice.html', form=form)
@app.route('/pitstops/create/<int:vid>', methods=['GET', 'POST']) @app.route('/pitstops/vehicle/<int:vid>/consumable/select', methods=['GET', 'POST'])
@login_required @login_required
def create_pit_stop_form(vid): def select_consumable_for_new_pitstop(vid):
vehicle = Vehicle.query.filter(Vehicle.id == vid).first() vehicle = Vehicle.query.get(vid)
if vehicle not in current_user.vehicles: if vehicle is None or vehicle not in current_user.vehicles:
return redirect(url_for('select_vehicle_for_new_pitstop')) return redirect(url_for('select_vehicle_for_new_pitstop'))
if len(vehicle.pitstops) > 0: if len(vehicle.consumables) == 1:
last_pitstop = vehicle.pitstops[-1] return redirect(url_for('create_pit_stop_form', vid=vid, cid=vehicle.consumables[0].id))
else:
last_pitstop = Pitstop(0, 0, date(1970, 1, 1), 0)
form = CreatePitstopForm() form = SelectConsumableForm()
form.set_pitstop(last_pitstop) form.consumable.choices = [(g.id, g.name) for g in vehicle.consumables]
if form.validate_on_submit(): if form.validate_on_submit():
new_stop = Pitstop(form.odometer.data, form.litres.data, form.date.data, form.costs.data) 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()
# the last pitstop is required to be able to check the monotonicy of date and odometer
last_pitstop = tools.get_latest_pitstop_for_vehicle(vid)
last_pitstop_consumable = tools.get_latest_pitstop_for_vehicle_and_consumable(vid, cid)
# we can enter the same odometer if the pitstops are not equal
form.same_odometer_allowed = (last_pitstop != last_pitstop_consumable)
# set the lower limits for odometer andd date and the values for amount and costs of the last stop
form.set_pitstop(tools.compute_lower_limits_for_new_pitstop(last_pitstop, last_pitstop_consumable, cid))
# 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) db.session.add(new_stop)
vehicle.pitstops.append(new_stop) vehicle.pitstops.append(new_stop)
db.session.commit() try:
tools.db_log_add(new_stop) db.session.commit()
tools.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))) 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.costs.default = last_pitstop.costs
form.process() form.process()
messages = { return render_template('createPitStopForm.html', form=form, vehicle=vehicle, messages=form.get_hint_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)),
'costs': 'Costs must be higher than 0.01 €.'
}
return render_template('newPitStopForm.html', form=form, vehicle=vehicle, messages = messages)
@app.route('/pitstops/delete/<int:pid>', methods=['GET', 'POST']) @app.route('/pitstops/delete/<int:pid>', methods=['GET', 'POST'])
@ -229,9 +310,10 @@ def delete_pit_stop_form(pid):
@app.route('/pitstops/edit/<int:pid>', methods=['GET', 'POST']) @app.route('/pitstops/edit/<int:pid>', methods=['GET', 'POST'])
@login_required @login_required
def edit_pit_stop_form(pid): def edit_pit_stop_form(pid):
edit_pitstop = Pitstop.query.filter(Pitstop.id == pid).first() edit_pitstop = Pitstop.query.get(pid).first()
if edit_pitstop is None: if edit_pitstop is None:
return redirect(url_for('get_pit_stops')) return redirect(url_for('get_pit_stops'))
vehicle = Vehicle.query.filter(Vehicle.id == edit_pitstop.vehicle_id).first() vehicle = Vehicle.query.filter(Vehicle.id == edit_pitstop.vehicle_id).first()
if vehicle not in current_user.vehicles: if vehicle not in current_user.vehicles:
return redirect(url_for('get_pit_stops')) return redirect(url_for('get_pit_stops'))
@ -240,7 +322,7 @@ def edit_pit_stop_form(pid):
if last_pitstop_pos > 0: if last_pitstop_pos > 0:
last_pitstop = vehicle.pitstops[last_pitstop_pos] last_pitstop = vehicle.pitstops[last_pitstop_pos]
else: else:
last_pitstop = Pitstop(0, 0, date(1970, 1, 1)) last_pitstop = Pitstop(0, 0, date(1970, 1, 1), 0, 0)
form = EditPitstopForm() form = EditPitstopForm()
form.set_pitstop(last_pitstop) form.set_pitstop(last_pitstop)
@ -283,8 +365,88 @@ def get_manual():
@app.route('/admin', methods=['GET']) @app.route('/admin', methods=['GET'])
@roles_required('admin') @roles_required('admin')
def get_admin_page(): def get_admin_page():
g.data['users'] = User.query.all() users = User.query.all()
return render_template('admin.html', data=g.data) 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()
tools.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()
tools.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()
tools.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)
@app.route('/account', methods=['GET']) @app.route('/account', methods=['GET'])

View File

@ -5,8 +5,15 @@ roles_users = db.Table('roles_users',
db.Column('user_id', db.Integer(), db.ForeignKey('user.id')), db.Column('user_id', db.Integer(), db.ForeignKey('user.id')),
db.Column('role_id', db.Integer(), db.ForeignKey('role.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): 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) id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(80), unique=True) name = db.Column(db.String(80), unique=True)
description = db.Column(db.String(255)) description = db.Column(db.String(255))
@ -19,11 +26,15 @@ class Role(db.Model, RoleMixin):
class User(db.Model, UserMixin): 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) id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True) email = db.Column(db.String(255), unique=True)
password = db.Column(db.String(255)) password = db.Column(db.String(255))
active = db.Column(db.Boolean()) active = db.Column(db.Boolean())
confirmed_at = db.Column(db.DateTime()) confirmed_at = db.Column(db.DateTime())
vehicles = db.relationship( vehicles = db.relationship(
'Vehicle' 'Vehicle'
) )
@ -38,13 +49,28 @@ class User(db.Model, UserMixin):
class Vehicle(db.Model): 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) id = db.Column(db.Integer, primary_key=True)
owner_id = db.Column(db.Integer, db.ForeignKey('user.id')) owner_id = db.Column(db.Integer, db.ForeignKey('user.id'))
name = db.Column(db.String(255)) name = db.Column(db.String(255))
pitstops = db.relationship( pitstops = db.relationship(
'Pitstop' 'Pitstop'
) )
__table_args__ = (db.UniqueConstraint('owner_id', 'name', name='_owner_name_uniq'),) 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): def __init__(self, name):
self.name = name self.name = name
@ -54,18 +80,63 @@ class Vehicle(db.Model):
class Pitstop(db.Model): 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) id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.Date) date = db.Column(db.Date)
odometer = db.Column(db.Integer) odometer = db.Column(db.Integer)
litres = db.Column(db.Numeric(5, 2)) 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) costs = db.Column(db.Numeric(5, 2), default=0)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicle.id')) 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, litres, date, costs): def __init__(self, odometer, amount, date, costs, consumable_id):
self.odometer = odometer self.odometer = odometer
self.litres = litres self.amount = amount
self.date = date self.date = date
self.costs = costs self.costs = costs
self.consumable_id = consumable_id
def __repr__(self): def __repr__(self):
return '<Pitstop odometer="%r" litres="%r" date="%r" vehicle_id="%r">' % (self.odometer, self.litres, self.date, self.vehicle_id) 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)

View File

@ -1,10 +1,17 @@
from flask_wtf import Form from flask_wtf import Form
from wtforms import DateField, IntegerField, DecimalField, StringField, SelectField, SubmitField from wtforms import DateField, IntegerField, DecimalField, StringField, SelectField, SubmitField, SelectMultipleField, BooleanField
from wtforms.validators import ValidationError, Length from wtforms.validators import ValidationError, Length
from datetime import date from datetime import date
def date_check(form, field): 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: if field.data < form.last_pitstop.date:
raise ValidationError('The new date must not be before %s' % form.last_pitstop.date) raise ValidationError('The new date must not be before %s' % form.last_pitstop.date)
if field.data > date.today(): if field.data > date.today():
@ -12,7 +19,15 @@ def date_check(form, field):
def odometer_check(form, field): def odometer_check(form, field):
if field.data <= form.last_pitstop.odometer: """
: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) raise ValidationError('The new odometer value must be higher than %i km' % form.last_pitstop.odometer)
@ -27,7 +42,13 @@ def costs_check(form, field):
def edit_costs_check(form, field): def edit_costs_check(form, field):
costs_check_required = (form.costs.default is not None and form.costs.default > 0) """
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: if costs_check_required and field.data is not None and field.data <= 0:
raise ValidationError('Costs must be above 0.01 €.') raise ValidationError('Costs must be above 0.01 €.')
@ -37,6 +58,11 @@ class SelectVehicleForm(Form):
submit = SubmitField(label='Do it!') submit = SubmitField(label='Do it!')
class SelectConsumableForm(Form):
consumable = SelectField('Consumable', coerce=int)
submit = SubmitField(label='Do it!')
class CreatePitstopForm(Form): class CreatePitstopForm(Form):
date = DateField('Date of Pitstop', validators=[date_check]) date = DateField('Date of Pitstop', validators=[date_check])
odometer = IntegerField('Odometer (km)', validators=[odometer_check]) odometer = IntegerField('Odometer (km)', validators=[odometer_check])
@ -44,13 +70,48 @@ class CreatePitstopForm(Form):
costs = DecimalField('Costs (€, overall)', places=2, validators=[costs_check]) costs = DecimalField('Costs (€, overall)', places=2, validators=[costs_check])
submit = SubmitField(label='Do it!') submit = SubmitField(label='Do it!')
last_pitstop = None last_pitstop = None
same_odometer_allowed = True
def set_pitstop(self, last_pitstop): def set_pitstop(self, last_pitstop):
self.last_pitstop = 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 = self.last_pitstop.date
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
class EditVehicleForm(Form): class EditVehicleForm(Form):
name = StringField('Name', validators=[Length(1, 255)]) name = StringField('Name', validators=[Length(1, 255)])
consumables = SelectMultipleField('Consumables', coerce=int,validators=[])
submit = SubmitField(label='Do it!') submit = SubmitField(label='Do it!')
@ -73,8 +134,36 @@ class EditPitstopForm(Form):
costs = DecimalField('Costs (€, overall)', places=2, validators=[edit_costs_check]) costs = DecimalField('Costs (€, overall)', places=2, validators=[edit_costs_check])
submit = SubmitField(label='Update it!') submit = SubmitField(label='Update it!')
last_pitstop = None last_pitstop = None
same_odometer_allowed = True
def set_pitstop(self, last_pitstop): def set_pitstop(self, last_pitstop):
self.last_pitstop = 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 CreateConsumableForm(Form):
name = StringField('Name', validators=[Length(1, 255)])
unit = StringField('Unit', validators=[Length(1, 255)])
submit = SubmitField(label='Do it!')
class EditConsumableForm(Form):
name = StringField('Name', validators=[Length(1, 255)])
unit = StringField('Unit', validators=[Length(1, 255)])
submit = SubmitField(label='Do it!')
class DeletConsumableForm(Form):
submit = SubmitField(label='Do it!')

View File

@ -36,7 +36,8 @@
{{ vehicle.name }} {{ vehicle.name }}
</td> </td>
<td> <td>
{{ vehicle.pitstops | length }} pitstops {{ vehicle.pitstops | length }} pitstops<br />
{{ vehicle.consumables | length }} consumables
</td> </td>
<td> <td>
<a href="{{ url_for('edit_vehicle', vid=vehicle.id) }}" class="btn btn-primary " role="button"> <a href="{{ url_for('edit_vehicle', vid=vehicle.id) }}" class="btn btn-primary " role="button">

View File

@ -1,18 +1,66 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div class="col-md-2" ></div> <h3>Admin</h3>
<div class="col-md-8">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading">Users</div>
<div class="panel-body"> <div class="panel-body">
<h3>Admin</h3> We have {{ users|length }} users so far:
We have {{ data.users|length }} users so far:
<ul> <ul>
{% for user in data.users %} {% for user in users %}
<li>{{user.email}}</li> <li>{{user.email}}</li>
{% endfor %} {% endfor %}
</ul>
</div> </div>
</div> </div>
</div>
<div class="panel panel-default">
<div class="panel-heading">Consumables</div>
<div class="panel-body">
<a href="{{ url_for('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>
{{ consumable.name }}
</td>
<td>
{{ consumable.unit }}
</td>
<td>
{{ consumable.vehicles | length }} vehicles
</td>
<td>
{% if not consumable.in_use %}
<a href="{{ url_for('delete_consumable', cid=consumable.id) }}" 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) }}" class="btn btn-primary " role="button">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %} {% 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

@ -26,4 +26,5 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-2" ></div>
{% endblock %} {% endblock %}

View File

@ -9,6 +9,7 @@
<form class='form-horizontal' method="POST"> <form class='form-horizontal' method="POST">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ render_field_with_errors(form.name) }} {{ render_field_with_errors(form.name) }}
{{ render_field_with_errors(form.consumables) }}
{{ render_field_with_errors(form.submit) }} {{ render_field_with_errors(form.submit) }}
</form> </form>
</div> </div>

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 '{{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,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

@ -9,6 +9,7 @@
<form class='form-horizontal' method="POST"> <form class='form-horizontal' method="POST">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ render_field_with_errors(form.name) }} {{ render_field_with_errors(form.name) }}
{{ render_field_with_errors(form.consumables) }}
{{ render_field_with_errors(form.submit) }} {{ render_field_with_errors(form.submit) }}
</form> </form>
</div> </div>

View File

@ -85,30 +85,6 @@
{% endmacro %} {% 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 }}()
{% endmacro %}
<!doctype html> <!doctype html>
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7" lang=""> <![endif]--> <!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7" lang=""> <![endif]-->

View File

@ -1,6 +1,7 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block body %} {% block body %}
<div id="content"> <div class="col-md-2" ></div>
<div class="col-md-8">
<ul id="tabs" class="nav nav-tabs" data-tabs="tabs"> <ul id="tabs" class="nav nav-tabs" data-tabs="tabs">
{% for vehicle in current_user.vehicles %} {% for vehicle in current_user.vehicles %}
<li {% if loop.first %}class="active" {%endif %}> <li {% if loop.first %}class="active" {%endif %}>
@ -15,93 +16,54 @@
<div class="tab-pane {% if loop.first %}active{% endif %}" id="v{{vehicle.id}}"> <div class="tab-pane {% if loop.first %}active{% endif %}" id="v{{vehicle.id}}">
<h3>{{vehicle.name}}</h3> <h3>{{vehicle.name}}</h3>
{% if vehicle.pitstops %} {% if vehicle.pitstops %}
<div class="table-responsive"> {% for pitstop in vehicle.pitstops|reverse %}
<table class="table table-striped table-bordered table-condensed"> <div class="panel panel-default">
<tr> <div class="panel-body">
<th> <table class="table table-striped table-bordered table-condensed">
Date<br/> <tr>
Days <th>Date</th>
</th> <td>{{pitstop.date}}</td>
<th> </tr>
Odometer<br/> <tr>
Distance <th>Odometer</th>
</th> <td>{{pitstop.odometer}} km</td>
<th> </tr>
Litres<br/> <tr>
Average <th>{{ pitstop.consumable.name }}</th>
</th> <td>{{pitstop.amount}} {{ pitstop.consumable.unit }}</td>
<th> </tr>
Costs<br /> <tr>
Costs per Litre <th>Costs</th>
</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> <td>
{{pitstop.date}}<br/> {% if pitstop.costs %}
{{ days }} days {{pitstop.costs}} €
</td> {% else %}
<td> -- €
{{pitstop.odometer}} km<br/> {% endif %}
{{distance}} km
</td>
<td>
{{pitstop.litres}} l<br/>
{{average | round(2)}} l/100km
</td>
<td>
{{pitstop.costs}} €<br />
{{ (pitstop.costs / pitstop.litres) | round(2) }} €/l
</td>
</tr>
{% if loop.first %}
<tr class='pitstop'>
<td colspan='4'>
<a href="{{ url_for('edit_pit_stop_form', pid=pitstop.id) }}" class="btn btn-primary">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
<a href="{{ url_for('delete_pit_stop_form', pid=pitstop.id) }}" class="btn btn-primary btn-warning ">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> delete
</a>
</td>
</tr>
{% endif %}
{% 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>
<td>
{{pitstop.costs}} €<br />
{{ (pitstop.costs / pitstop.litres) | round(2) }} €/l
</td> </td>
</tr> </tr>
</table>
{% if loop.first %}
<a href="{{ url_for('edit_pit_stop_form', pid=pitstop.id) }}" class="btn btn-primary">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> edit
</a>
<a href="{{ url_for('delete_pit_stop_form', pid=pitstop.id) }}" class="btn btn-primary btn-warning ">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> delete
</a>
{% endif %} {% endif %}
{% endfor %} </div>
</table> </div>
</div> {% endfor %}
{% else %} {% else %}
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
not enough data: <a href="{{ url_for('create_pit_stop_form', vid=vehicle.id) }}">log a pitstop</a>? not enough data: <a href="{{ url_for('select_consumable_for_new_pitstop', vid=vehicle.id) }}">log a pitstop</a>?
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="col-md-2" ></div>
<script type="text/javascript"> <script type="text/javascript">

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

@ -1,161 +1,175 @@
{% extends "layout.html" %} {% 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 %} {% block body %}
<div id="content"> <div id="content">
<ul id="vehicle_tabs" class="nav nav-tabs" data-tabs="tabs"> <ul id="vehicle_tabs" class="nav nav-tabs" data-tabs="tabs">
{% for vehicle in data %} {% for vehicle in data %}
<li {% if loop.first %}class="active" {%endif %}> {{ nav_tab(vehicle.id, vehicle.name, loop.first) }}
<a href="#v{{vehicle.id}}" id="i{{vehicle.id}}" data-toggle="tab">
{{ vehicle.name }}
</a>
</li>
{% endfor %} {% endfor %}
</ul> </ul>
<div id="vehicle_content" class="tab-content "> <div id="vehicle_content" class="tab-content ">
{% for vehicle in data %} {% for vehicle in data %}
<div class="tab-pane {% if loop.first %}active{% endif %}" id="v{{vehicle.id}}"> <div class="tab-pane {% if loop.first %}active{% endif %}" id="ref_{{vehicle.id}}">
<h3>{{vehicle.name}}</h3> {{ print_vehicle_table(vehicle) }}
<div class="table-responsive"> <ul id="vehicle_{{vehicle.id}}_tabs" class="nav nav-tabs" data-tabs="tabs">
<table class="table table-striped table-bordered table-condensed"> {{ nav_tab(vehicle.id|string + '_odometer', 'Odometer', true) }}
<tr> {% for consumable in vehicle.consumables %}
<th>Number of Pitstops:</th> {{ nav_tab(vehicle.id|string + '_' + consumable.id|string, consumable.name, false) }}
<td>{{ vehicle.pitstop_count }}</td> {% endfor %}
</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>
<tr>
<th>Logged Costs:</th>
<td>{{ vehicle.overall_costs | round(2) }} €</td>
</tr>
<tr>
<th>Average Costs per Litre:</th>
<td>{{ vehicle.average_costs_per_litre | round(2) }} €/l</td>
</tr>
</table>
</div>
<ul id="charts_tabs" class="nav nav-tabs" data-tabs="tabs">
<li class="active">
<a href="#v{{vehicle.id}}_c3" id="i{{vehicle.id}}_c3" data-toggle="tab">
Consumption
</a>
</li>
<li>
<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}}_c4" id="i{{vehicle.id}}_c4" data-toggle="tab">
Costs per litre
</a>
</li>
<li>
<a href="#v{{vehicle.id}}_c5" id="i{{vehicle.id}}_c5" data-toggle="tab">
Costs
</a>
</li>
</ul> </ul>
<div id="charts_tabs-content" class="tab-content"> <div id="vehicle_{{vehicle.id}}_content" class="tab-content ">
<div class="tab-pane active" id="v{{vehicle.id}}_c3"> {{ tab_pane(
{% if vehicle.pitstop_count > 1 %} vehicle.id|string + '_odometer',
<div id="averageUsageDiv{{vehicle.id}}" style="width:100%; height:500px;"></div> chart(
<script type="text/javascript"> vehicle.odometers,
{{ chartScript('averageUsageDiv'+vehicle.id|str, vehicle.average_litres, 'l/100 km') }} 'ref_' + vehicle.id|string + '_odometer',
</script> 'km',
{% else %} url_for('select_consumable_for_new_pitstop', vid=vehicle.id)
<div class="alert alert-warning" role="alert"> ),
not enough data: <a href="{{ url_for('create_pit_stop_form', vid=vehicle.id) }}">log a pitstop</a>? 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> </div>
{% endif %} {{ tab_script('vehicle_' + vehicle.id|string + '_' + consumable.id|string + '_tabs') }}
</div> </div>
<div class="tab-pane " id="v{{vehicle.id}}_c1"> {% endfor %}
{% 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 %}
<div class="alert alert-warning" role="alert">
not enough data: <a href="{{ url_for('create_pit_stop_form', vid=vehicle.id) }}">log a pitstop</a>?
</div>
{% 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 %}
<div class="alert alert-warning" role="alert">
not enough data: <a href="{{ url_for('create_pit_stop_form', vid=vehicle.id) }}">log a pitstop</a>?
</div>
{% endif %}
</div>
<div class="tab-pane " id="v{{vehicle.id}}_c4">
{% if vehicle.pitstop_count > 0 %}
<div id="costsPerLitre{{vehicle.id}}" style="width:100%; height:500px;"></div>
<script type="text/javascript">
{{ chartScript('costsPerLitre'+vehicle.id|str, vehicle.costsPerLitre, '€/l') }}
</script>
{% else %}
<div class="alert alert-warning" role="alert">
not enough data: <a href="{{ url_for('create_pit_stop_form', vid=vehicle.id) }}">log a pitstop</a>?
</div>
{% endif %}
</div>
<div class="tab-pane " id="v{{vehicle.id}}_c5">
{% if vehicle.pitstop_count > 0 %}
<div id="costs{{vehicle.id}}" style="width:100%; height:500px;"></div>
<script type="text/javascript">
{{ chartScript('costs'+vehicle.id|str, vehicle.costs, '€') }}
</script>
{% else %}
<div class="alert alert-warning" role="alert">
not enough data: <a href="{{ url_for('create_pit_stop_form', vid=vehicle.id) }}">log a pitstop</a>?
</div>
{% endif %}
</div>
</div> </div>
{{ tab_script('vehicle_' + vehicle.id|string + '_tabs') }}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{{ tab_script('vehicle_tabs') }}
</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 %} {% endblock %}

View File

@ -1,52 +1,64 @@
import logging 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: class VehicleStats:
def __init__(self, vehicle): def __init__(self, vehicle):
self.name = vehicle.name self.name = vehicle.name
self.id = vehicle.id self.id = vehicle.id
self.pitstop_count = len(vehicle.pitstops)
self.overall_distance = 0 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 = []
self.costsPerLitre = []
self.costs = []
self.overall_costs = 0 self.overall_costs = 0
self.average_costs_per_litre = 0 self.consumables = []
cost_count = 0; self.odometers = []
if self.pitstop_count > 0: for consumable in vehicle.consumables:
for pitstop in vehicle.pitstops: self.consumables.append(ConsumableStats(vehicle, consumable))
self.overall_litres += pitstop.litres
self.litres.append(StatsEvent(pitstop.date, pitstop.litres))
self.odometers.append(StatsEvent(pitstop.date, pitstop.odometer))
self.costsPerLitre.append(StatsEvent(pitstop.date, pitstop.costs / pitstop.litres))
self.costs.append(StatsEvent(pitstop.date, pitstop.costs))
self.overall_costs += pitstop.costs
self.average_costs_per_litre += (pitstop.costs / pitstop.litres)
if pitstop.costs > 0:
cost_count += 1
self.average_litres_fuelled = self.overall_litres / self.pitstop_count
if cost_count > 0:
self.average_costs_per_litre = self.average_costs_per_litre / cost_count
else:
self.average_costs_per_litre = 0
if self.pitstop_count > 1: pitstop_count = len(vehicle.pitstops)
if pitstop_count > 0:
for pitstop in vehicle.pitstops:
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 = vehicle.pitstops[-1].odometer - vehicle.pitstops[0].odometer 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: class StatsEvent:
@ -82,4 +94,84 @@ def check_vehicle_name_is_unique(current_user, name_field):
name_field.default = vehicle_name name_field.default = vehicle_name
name_field.errors.append('Vehicle "%s" already exists.' % vehicle_name) name_field.errors.append('Vehicle "%s" already exists.' % vehicle_name)
return False return False
return True 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)

View File

@ -9,10 +9,12 @@ class Config:
SECURITY_RECOVERABLE = True SECURITY_RECOVERABLE = True
SECURITY_PASSWORD_SALT = os.environ.get('SECURITY_PASSWORD_SALT') or 'SOME SECRET STRING' SECURITY_PASSWORD_SALT = os.environ.get('SECURITY_PASSWORD_SALT') or 'SOME SECRET STRING'
SECRET_KEY = os.environ.get('SECRET_KEY') 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 SQLALCHEMY_TRACK_MODIFICATIONS = False
MAIL_SERVER = 'smtp.googlemail.com' MAIL_SERVER = 'mail.nerd2nerd.org'
MAIL_PORT = 587 MAIL_PORT = 25
MAIL_USE_TLS = True MAIL_USE_TLS = True
MAIL_USE_SSL = False
MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
@ -24,7 +26,7 @@ class Config:
class DevelopmentConfig(Config): class DevelopmentConfig(Config):
SECURITY_SEND_REGISTER_EMAIL = False SECURITY_SEND_REGISTER_EMAIL = False
DEBUG = True DEBUG = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data_dev.sqlite') SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:%s@database/pitstops_old' % (os.environ['DATABASE_ENV_MYSQL_ROOT_PASSWORD'])
class TestingConfig(Config): class TestingConfig(Config):

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