Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b78dbe5b7 | |||
| 95a7e56a32 | |||
| 9eb4d5ada0 | |||
| f7cb273254 | |||
| 543d3e1658 | |||
| 9427ed50ad | |||
| 28990f27fa | |||
| d55fd3d9f6 | |||
| e9bb7986f4 | |||
| eaacd3f42e | |||
| 8a99e4a616 | |||
| 028b52d12f | |||
| 958a9bdd9f | |||
| b02938cafe | |||
| 47e81c7517 |
@@ -1,12 +1,13 @@
|
|||||||
from flask import Flask
|
from flask import Flask, make_response
|
||||||
from flask import g
|
from flask import g
|
||||||
from flask_mail import Mail
|
from flask_mail import Mail
|
||||||
from flask_security import Security, SQLAlchemyUserDatastore, user_registered
|
from flask_security import Security, SQLAlchemyUserDatastore, user_registered
|
||||||
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 flask.ext.security.forms import LoginForm
|
from flask_security.forms import LoginForm
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
|
||||||
from .forms import *
|
from .forms import *
|
||||||
|
|
||||||
@@ -14,6 +15,19 @@ from .forms import *
|
|||||||
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'])
|
||||||
|
|
||||||
|
limiter = Limiter(
|
||||||
|
app,
|
||||||
|
key_func=get_remote_address,
|
||||||
|
default_limits=["200 per day", "50 per hour"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(429)
|
||||||
|
def ratelimit_handler(e):
|
||||||
|
return make_response(
|
||||||
|
jsonify(error="ratelimit exceeded %s" % e.description)
|
||||||
|
, 429
|
||||||
|
)
|
||||||
db = SQLAlchemy(app)
|
db = SQLAlchemy(app)
|
||||||
mail = Mail(app)
|
mail = Mail(app)
|
||||||
|
|
||||||
@@ -47,9 +61,21 @@ def user_registered_sighandler(application, user, confirm_token):
|
|||||||
tools.db_log_add(new_vehicle)
|
tools.db_log_add(new_vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
def assure_consumable(name, ext_id, unit):
|
||||||
|
if not Consumable.query.filter(Consumable.ext_id == ext_id).first():
|
||||||
|
c = Consumable(name, ext_id, unit)
|
||||||
|
db.session.add(c)
|
||||||
|
|
||||||
|
|
||||||
@app.before_first_request
|
@app.before_first_request
|
||||||
def before_first_request():
|
def before_first_request():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
|
||||||
|
# make sure all consumables from tankerkoenig exist: diesel, e5, e10
|
||||||
|
assure_consumable('Diesel', 'diesel', 'L')
|
||||||
|
assure_consumable('Super','e5', 'L')
|
||||||
|
assure_consumable('Super E10','e10', 'L')
|
||||||
|
|
||||||
user_datastore.find_or_create_role(name='admin', description='Role for administrators')
|
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.')
|
user_datastore.find_or_create_role(name='user', description='Role for all users.')
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ vehicles_consumables = db.Table('vehicles_consumables',
|
|||||||
db.Column('consumable_id', db.Integer(), db.ForeignKey('consumable.id')))
|
db.Column('consumable_id', db.Integer(), db.ForeignKey('consumable.id')))
|
||||||
|
|
||||||
|
|
||||||
|
users_fillingstations = db.Table('users_fillingstations',
|
||||||
|
db.Column('user_id', db.Integer(), db.ForeignKey('user.id')),
|
||||||
|
db.Column('fillingstation_id', db.Integer(), db.ForeignKey('filling_station.int_id')))
|
||||||
|
|
||||||
|
|
||||||
class Role(db.Model, RoleMixin):
|
class Role(db.Model, RoleMixin):
|
||||||
"""
|
"""
|
||||||
Entity to handle different roles for users: Typically user and admin exist
|
Entity to handle different roles for users: Typically user and admin exist
|
||||||
@@ -34,6 +39,9 @@ class User(db.Model, UserMixin):
|
|||||||
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())
|
||||||
|
home_lat = db.Column(db.Numeric(8, 5), default=0)
|
||||||
|
home_long = db.Column(db.Numeric(8, 5), default=0)
|
||||||
|
home_zoom = db.Column(db.Integer(), default=0)
|
||||||
|
|
||||||
vehicles = db.relationship(
|
vehicles = db.relationship(
|
||||||
'Vehicle'
|
'Vehicle'
|
||||||
@@ -43,6 +51,10 @@ class User(db.Model, UserMixin):
|
|||||||
secondary=roles_users,
|
secondary=roles_users,
|
||||||
backref=db.backref('users', lazy='dynamic')
|
backref=db.backref('users', lazy='dynamic')
|
||||||
)
|
)
|
||||||
|
favourite_filling_stations = db.relationship(
|
||||||
|
'FillingStation',
|
||||||
|
secondary=users_fillingstations
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<User id="%r" email="%r" ' % (self.id, self.email)
|
return '<User id="%r" email="%r" ' % (self.id, self.email)
|
||||||
@@ -130,6 +142,7 @@ class Consumable(db.Model):
|
|||||||
"""
|
"""
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(255), unique=True)
|
name = db.Column(db.String(255), unique=True)
|
||||||
|
ext_id = db.Column(db.String(255))
|
||||||
unit = db.Column(db.String(255))
|
unit = db.Column(db.String(255))
|
||||||
|
|
||||||
vehicles = db.relationship(
|
vehicles = db.relationship(
|
||||||
@@ -137,8 +150,9 @@ class Consumable(db.Model):
|
|||||||
secondary=vehicles_consumables
|
secondary=vehicles_consumables
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, name, unit):
|
def __init__(self, name, ext_id, unit):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.ext_id = ext_id
|
||||||
self.unit = unit
|
self.unit = unit
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -163,3 +177,32 @@ class Service(db.Model):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return '<Service odometer="%r" date="%r" vehicle_id="%r" costs="%r" description="%r">' % \
|
return '<Service odometer="%r" date="%r" vehicle_id="%r" costs="%r" description="%r">' % \
|
||||||
(self.odometer, self.date, self.vehicle_id, self.costs, self.description)
|
(self.odometer, self.date, self.vehicle_id, self.costs, self.description)
|
||||||
|
|
||||||
|
|
||||||
|
class FillingStation(db.Model):
|
||||||
|
int_id = db.Column(db.Integer, primary_key=True)
|
||||||
|
id = db.Column(db.String(40), unique=True, nullable=False)
|
||||||
|
name = db.Column(db.Text(), nullable=False)
|
||||||
|
street = db.Column(db.Text(), nullable=False)
|
||||||
|
place = db.Column(db.Text(), nullable=False)
|
||||||
|
houseNumber = db.Column(db.Text())
|
||||||
|
postCode = db.Column(db.Integer(), nullable=False)
|
||||||
|
brand = db.Column(db.Text(), nullable=False)
|
||||||
|
lat = db.Column(db.Numeric(8, 5), nullable=False)
|
||||||
|
lng = db.Column(db.Numeric(8, 5), nullable=False)
|
||||||
|
last_update = db.Column(db.DateTime)
|
||||||
|
diesel = db.Column(db.Numeric(10, 3), default=0)
|
||||||
|
e5 = db.Column(db.Numeric(10, 3), default=0)
|
||||||
|
e10 = db.Column(db.Numeric(10, 3), default=0)
|
||||||
|
open = db.Column(db.Boolean())
|
||||||
|
|
||||||
|
def as_dict(self):
|
||||||
|
res = {}
|
||||||
|
for c in self.__table__.columns:
|
||||||
|
val = getattr(self, c.name)
|
||||||
|
import decimal
|
||||||
|
if isinstance(val, decimal.Decimal):
|
||||||
|
val = float(val)
|
||||||
|
val = str(val)
|
||||||
|
res[c.name] = val
|
||||||
|
return res
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ class SelectConsumableForm(FlaskForm):
|
|||||||
|
|
||||||
class CreateConsumableForm(FlaskForm):
|
class CreateConsumableForm(FlaskForm):
|
||||||
name = StringField('Name', validators=[Length(1, 255)])
|
name = StringField('Name', validators=[Length(1, 255)])
|
||||||
|
ext_id = SelectField('Tankerkönig ID', coerce=int)
|
||||||
unit = StringField('Unit', validators=[Length(1, 255)])
|
unit = StringField('Unit', validators=[Length(1, 255)])
|
||||||
submit = SubmitField(label='Do it!')
|
submit = SubmitField(label='Do it!')
|
||||||
|
|
||||||
|
|
||||||
class EditConsumableForm(FlaskForm):
|
class EditConsumableForm(FlaskForm):
|
||||||
name = StringField('Name', validators=[Length(1, 255)])
|
name = StringField('Name', validators=[Length(1, 255)])
|
||||||
|
ext_id = SelectField('Tankerkönig ID', coerce=int)
|
||||||
unit = StringField('Unit', validators=[Length(1, 255)])
|
unit = StringField('Unit', validators=[Length(1, 255)])
|
||||||
submit = SubmitField(label='Do it!')
|
submit = SubmitField(label='Do it!')
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ from .admin import *
|
|||||||
from .misc import *
|
from .misc import *
|
||||||
from .pitstop import *
|
from .pitstop import *
|
||||||
from .service import *
|
from .service import *
|
||||||
|
from .filling_stations import *
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
from flask import url_for, redirect, render_template
|
from flask import url_for, redirect, render_template, request, jsonify
|
||||||
from flask_security import login_required
|
from flask_security import login_required
|
||||||
from flask_security.core import current_user
|
from flask_security.core import current_user
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
import json
|
||||||
|
|
||||||
from ..entities import Vehicle, Consumable
|
from ..entities import Vehicle, Consumable
|
||||||
from ..forms import EditVehicleForm, DeleteVehicleForm, DeleteAccountForm
|
from ..forms import EditVehicleForm, DeleteVehicleForm, DeleteAccountForm
|
||||||
@@ -12,7 +13,12 @@ from .. import app, db, user_datastore
|
|||||||
@app.route('/account', methods=['GET'])
|
@app.route('/account', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def get_account_page():
|
def get_account_page():
|
||||||
return render_template('account.html')
|
stations = [x.as_dict() for x in current_user.favourite_filling_stations]
|
||||||
|
for station in stations:
|
||||||
|
station['state'] = 'favourite'
|
||||||
|
return render_template('account.html',
|
||||||
|
map_pos=(current_user.home_lat, current_user.home_long, current_user.home_zoom),
|
||||||
|
fs=json.dumps(stations))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/account/vehicle/edit/<int:vid>', methods=['GET', 'POST'])
|
@app.route('/account/vehicle/edit/<int:vid>', methods=['GET', 'POST'])
|
||||||
@@ -37,7 +43,7 @@ def edit_vehicle(vid):
|
|||||||
vehicle.name = form.name.data
|
vehicle.name = form.name.data
|
||||||
# we cannot delete consumables where there are pitstops for => report error
|
# we cannot delete consumables where there are pitstops for => report error
|
||||||
vehicle.consumables = []
|
vehicle.consumables = []
|
||||||
for consumable_id in form.consumables.data:
|
for consumable_id in form.consumables.data:
|
||||||
consumable = Consumable.query.get(consumable_id)
|
consumable = Consumable.query.get(consumable_id)
|
||||||
if consumable is not None:
|
if consumable is not None:
|
||||||
vehicle.consumables.append(consumable)
|
vehicle.consumables.append(consumable)
|
||||||
@@ -129,3 +135,21 @@ def delete_account():
|
|||||||
|
|
||||||
return render_template('deleteAccountForm.html', form=form)
|
return render_template('deleteAccountForm.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/account/home', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
def get_users_home():
|
||||||
|
return jsonify(
|
||||||
|
{'lat': float(current_user.home_lat), 'long': float(current_user.home_long), 'zoom': current_user.home_zoom})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/account/home', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def set_users_home():
|
||||||
|
current_user.home_lat = request.json['lat']
|
||||||
|
current_user.home_long = request.json['long']
|
||||||
|
current_user.home_zoom = request.json['zoom']
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ def get_admin_page():
|
|||||||
@login_required
|
@login_required
|
||||||
def create_consumable():
|
def create_consumable():
|
||||||
form = CreateConsumableForm()
|
form = CreateConsumableForm()
|
||||||
|
choices = [(0, ''), (1, 'diesel'), (2, 'e5'), (3, 'e10')]
|
||||||
|
form.ext_id.choices = choices
|
||||||
|
|
||||||
# preinitialize the defaults with potentially existing values from a try before
|
# preinitialize the defaults with potentially existing values from a try before
|
||||||
if form.name.data is not None:
|
if form.name.data is not None:
|
||||||
@@ -30,7 +32,7 @@ def create_consumable():
|
|||||||
form.unit.default = form.unit.data
|
form.unit.default = form.unit.data
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
new_consumable = Consumable(form.name.data, form.unit.data)
|
new_consumable = Consumable(form.name.data, choices[form.ext_id.data][1], form.unit.data)
|
||||||
db.session.add(new_consumable)
|
db.session.add(new_consumable)
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -70,19 +72,28 @@ def edit_consumable(cid):
|
|||||||
return redirect(url_for('get_admin_page'))
|
return redirect(url_for('get_admin_page'))
|
||||||
|
|
||||||
form = EditConsumableForm()
|
form = EditConsumableForm()
|
||||||
|
choices = [(0, ''), (1, 'diesel'), (2, 'e5'), (3, 'e10')]
|
||||||
|
form.ext_id.choices = choices
|
||||||
|
|
||||||
form.name.default = consumable.name
|
form.name.default = consumable.name
|
||||||
form.unit.default = consumable.unit
|
form.unit.default = consumable.unit
|
||||||
|
form.ext_id.default = 3
|
||||||
|
for c in choices:
|
||||||
|
if c[1] == consumable.ext_id:
|
||||||
|
form.ext_id.default = c[0]
|
||||||
|
|
||||||
# preinitialize the defaults with potentially existing values from a try before
|
# preinitialize the defaults with potentially existing values from a try before
|
||||||
if form.name.data is not None:
|
if form.name.data is not None:
|
||||||
form.name.default = form.name.data
|
form.name.default = form.name.data
|
||||||
if form.unit.data is not None:
|
if form.unit.data is not None:
|
||||||
form.unit.default = form.unit.data
|
form.unit.default = form.unit.data
|
||||||
|
if form.ext_id.data is not None:
|
||||||
|
form.ext_id.default = form.ext_id.data
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
consumable.name = form.name.data
|
consumable.name = form.name.data
|
||||||
consumable.unit = form.unit.data
|
consumable.unit = form.unit.data
|
||||||
|
consumable.ext_id = choices[form.ext_id.data][1]
|
||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
db_log_update(consumable)
|
db_log_update(consumable)
|
||||||
|
|||||||
62
app/routes/filling_stations.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
from flask_security import login_required
|
||||||
|
from flask_security.core import current_user
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from ..entities import FillingStation
|
||||||
|
from .. import app, db, limiter
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/filling_stations/favourites/toggle/<fsid>')
|
||||||
|
def add_favourite_filling_stations(fsid):
|
||||||
|
favourite_ids = {x.id: x for x in current_user.favourite_filling_stations}
|
||||||
|
|
||||||
|
if fsid in favourite_ids:
|
||||||
|
current_user.favourite_filling_stations.remove(favourite_ids[fsid])
|
||||||
|
state = 'normal'
|
||||||
|
else:
|
||||||
|
fs = FillingStation.query.filter(FillingStation.id == fsid).first()
|
||||||
|
current_user.favourite_filling_stations.append(fs)
|
||||||
|
state = 'favourite'
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'state': state})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/filling_stations', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@limiter.limit('1 per second')
|
||||||
|
def query_filling_stations():
|
||||||
|
api_key = app.config['TANKERKOENIG_API_KEY']
|
||||||
|
|
||||||
|
latitude = request.args.get('latitude')
|
||||||
|
longitude = request.args.get('longitude')
|
||||||
|
radius = request.args.get('radius', default=1.5)
|
||||||
|
gas_type = request.args.get('type', default='all')
|
||||||
|
sort = request.args.get('sort', default='dist')
|
||||||
|
|
||||||
|
url = 'https://creativecommons.tankerkoenig.de/json/list.php'
|
||||||
|
params = {
|
||||||
|
'lat': latitude, 'lng': longitude, 'rad': radius, 'apikey': api_key, 'type': gas_type, 'sort': sort
|
||||||
|
}
|
||||||
|
response = requests.get(url, params=params)
|
||||||
|
data = response.json()
|
||||||
|
for station in data['stations']:
|
||||||
|
fs = FillingStation.query.filter(FillingStation.id == station['id']).first()
|
||||||
|
if not fs:
|
||||||
|
fs = FillingStation()
|
||||||
|
fs.id = station['id']
|
||||||
|
fs.brand = station['brand']
|
||||||
|
fs.lat = station['lat']
|
||||||
|
fs.lng = station['lng']
|
||||||
|
fs.name = station['name']
|
||||||
|
fs.street = station['street']
|
||||||
|
fs.place = station['place']
|
||||||
|
fs.houseNumber = station['houseNumber']
|
||||||
|
fs.postCode = station['postCode']
|
||||||
|
db.session.add(fs)
|
||||||
|
if fs in current_user.favourite_filling_stations:
|
||||||
|
station['state'] = 'favourite'
|
||||||
|
else:
|
||||||
|
station['state'] = 'normal'
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(data)
|
||||||
@@ -6,9 +6,8 @@ from datetime import date
|
|||||||
|
|
||||||
from ..entities import Vehicle, Consumable, Pitstop
|
from ..entities import Vehicle, Consumable, Pitstop
|
||||||
from ..forms import SelectVehicleForm, SelectConsumableForm, CreatePitstopForm, EditPitstopForm, DeletePitStopForm
|
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, \
|
from ..tools import db_log_update, db_log_delete, db_log_add, pitstop_service_key, \
|
||||||
get_latest_pitstop_for_vehicle_and_consumable, compute_lower_limits_for_new_pitstop, pitstop_service_key, \
|
get_event_line_for_vehicle, update_filling_station_prices
|
||||||
get_event_line_for_vehicle
|
|
||||||
from .. import app, db
|
from .. import app, db
|
||||||
|
|
||||||
|
|
||||||
@@ -182,3 +181,63 @@ def get_pit_stops():
|
|||||||
user['vehicles'].append(v)
|
user['vehicles'].append(v)
|
||||||
|
|
||||||
return render_template('pitstops.html', user=user)
|
return render_template('pitstops.html', user=user)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/pitstops/plan/vehicle/select', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def select_vehicle_for_plan_pitstop():
|
||||||
|
if len(current_user.vehicles) == 1:
|
||||||
|
return redirect(url_for('select_consumable_for_plan_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_plan_pitstop', vid=form.vehicle.data))
|
||||||
|
|
||||||
|
return render_template('selectVehicle.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/pitstops/plan/vehicle/<int:vid>/consumable/select', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def select_consumable_for_plan_pitstop(vid):
|
||||||
|
vehicle = Vehicle.query.get(vid)
|
||||||
|
if vehicle is None or vehicle not in current_user.vehicles:
|
||||||
|
return redirect(url_for('select_consumable_for_plan_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('plan_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('plan_pit_stop_form', vid=vid, cid=form.consumable.data))
|
||||||
|
|
||||||
|
return render_template('selectConsumableForVehicle.html', vehicle=vehicle, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/pitstops/plan/vehicle/<int:vid>/consumable/<int:cid>', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def plan_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))
|
||||||
|
|
||||||
|
update_filling_station_prices([x.id for x in current_user.favourite_filling_stations])
|
||||||
|
|
||||||
|
offers = []
|
||||||
|
for fs in current_user.favourite_filling_stations:
|
||||||
|
offers.append((fs, getattr(fs, consumable.ext_id),))
|
||||||
|
|
||||||
|
return render_template('planPitStopForm.html', vehicle=vehicle, consumable=consumable, offers=offers)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -92,4 +92,35 @@ and (max-device-width : 568px) {
|
|||||||
#charts_tabs-content {
|
#charts_tabs-content {
|
||||||
display:none;
|
display:none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filling_station_info {
|
||||||
|
margin: 5px;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filling_station_info img{
|
||||||
|
margin-top: 6px;
|
||||||
|
height:48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* styling for sortable tables
|
||||||
|
*/
|
||||||
|
th.header {
|
||||||
|
background-image: url(../img/updown.gif);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center right;
|
||||||
|
}
|
||||||
|
|
||||||
|
th.headerSortUp {
|
||||||
|
background-image: url(../img/down.gif);
|
||||||
|
}
|
||||||
|
|
||||||
|
th.headerSortDown {
|
||||||
|
background-image: url(../img/up.gif);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filling_station_closed {
|
||||||
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
BIN
app/static/img/down.gif
Normal file
|
After Width: | Height: | Size: 54 B |
|
Before Width: | Height: | Size: 980 B After Width: | Height: | Size: 980 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
BIN
app/static/img/filling_station_favourite_marker.png
Normal file
|
After Width: | Height: | Size: 725 B |
BIN
app/static/img/filling_station_marker.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
app/static/img/up.gif
Normal file
|
After Width: | Height: | Size: 54 B |
BIN
app/static/img/updown.gif
Normal file
|
After Width: | Height: | Size: 64 B |
4
app/static/jquery-1.11.2.min.js
vendored
184
app/static/js/fillingstations.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// initially display germany
|
||||||
|
var lat = 50.75653081787912,
|
||||||
|
lon = 9.262980794432847,
|
||||||
|
zoom = 5;
|
||||||
|
|
||||||
|
var map;
|
||||||
|
|
||||||
|
var filling_stations = {};
|
||||||
|
var filling_station_markers;
|
||||||
|
|
||||||
|
query_location = function(updater) {
|
||||||
|
if(navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(function(position) {
|
||||||
|
lat = position.coords.latitude;
|
||||||
|
lon = position.coords.longitude;
|
||||||
|
zoom = 11;
|
||||||
|
if(updater){
|
||||||
|
updater(lat, lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update_map = function() {
|
||||||
|
var lonLat = new OpenLayers.LonLat( lon, lat )
|
||||||
|
.transform(
|
||||||
|
new OpenLayers.Projection("EPSG:4326"), // transform from WGS 1984
|
||||||
|
map.getProjectionObject() // to Spherical Mercator Projection
|
||||||
|
);
|
||||||
|
map.setCenter (lonLat, zoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
load_filling_stations = function() {
|
||||||
|
var url = '/filling_stations?latitude=' + lat + '&longitude='+ lon + '&type=all&radius=5&sort=dist';
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: url,
|
||||||
|
success: function(data) {
|
||||||
|
data.stations.forEach(function(station) {
|
||||||
|
if (!(station.id in filling_stations)) {
|
||||||
|
filling_stations[station.id] = station;
|
||||||
|
filling_stations[station.id].marker = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
update_filling_station_markers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clicked_on_filling_station_marker = function(station, marker) {
|
||||||
|
return function(data) {
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: '/filling_stations/favourites/toggle/'+station.id,
|
||||||
|
dataType: 'json',
|
||||||
|
timeout: 1000,
|
||||||
|
success: function(data) {
|
||||||
|
if (data.state == 'favourite') {
|
||||||
|
marker.setUrl('/static/img/filling_station_favourite_marker.png');
|
||||||
|
} else {
|
||||||
|
marker.setUrl('/static/img/filling_station_marker.png');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentType : 'application/json'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
display_station_information = function(station) {
|
||||||
|
return function(event) {
|
||||||
|
var info = $('#station_info');
|
||||||
|
info.empty();
|
||||||
|
info.addClass('filling_station_info');
|
||||||
|
|
||||||
|
var cell1 = $('<div>', {'class':'col-md-8'})
|
||||||
|
var cell2 = $('<div>', {'class':'col-md-4'})
|
||||||
|
|
||||||
|
var img = $('<img>', {'src': '/static/logos/'+station.brand.toLowerCase()+'.png'});
|
||||||
|
// hide if the brand icon loads with error
|
||||||
|
img.error(function(){
|
||||||
|
$(this).hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
var name = $('<div>').text(station.name);
|
||||||
|
|
||||||
|
var street_number = $('<div>').text(station.street + ' ' + station.houseNumber);
|
||||||
|
|
||||||
|
var postcode_place = $('<div>').text(station.postCode + ' ' + station.place);
|
||||||
|
|
||||||
|
info.append(cell1
|
||||||
|
.append(name)
|
||||||
|
.append(street_number)
|
||||||
|
.append(postcode_place))
|
||||||
|
.append(cell2
|
||||||
|
.append(img));
|
||||||
|
|
||||||
|
cell2.height(cell1.height());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
update_filling_station_markers = function() {
|
||||||
|
for(id in filling_stations) {
|
||||||
|
var station = filling_stations[id];
|
||||||
|
if(!station.marker) {
|
||||||
|
var lonLat = new OpenLayers.LonLat(station.lng, station.lat)
|
||||||
|
.transform(new OpenLayers.Projection('EPSG:4326'), map.getProjectionObject());
|
||||||
|
if (station.state == 'favourite') {
|
||||||
|
var icon = new OpenLayers.Icon('/static/img/filling_station_favourite_marker.png');
|
||||||
|
} else {
|
||||||
|
var icon = new OpenLayers.Icon('/static/img/filling_station_marker.png');
|
||||||
|
}
|
||||||
|
var marker = new OpenLayers.Marker(lonLat, icon);
|
||||||
|
marker.events.register('click', marker, clicked_on_filling_station_marker(station, marker));
|
||||||
|
marker.events.register('mouseover', null, display_station_information(station));
|
||||||
|
filling_station_markers.addMarker(marker);
|
||||||
|
station.marker = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activate_map = function(map_div_id, button_ids, home_lat, home_long, home_zoom, init_stations) {
|
||||||
|
|
||||||
|
// resize to reasonable height
|
||||||
|
$('#' + map_div_id).css('height',0.75*($('#' + map_div_id).css('width')));
|
||||||
|
|
||||||
|
// init map
|
||||||
|
map = new OpenLayers.Map(map_div_id);
|
||||||
|
map.addLayer(new OpenLayers.Layer.OSM());
|
||||||
|
map.events.register('moveend', null, function(e){
|
||||||
|
var p = e.object.center.clone();
|
||||||
|
var p = p.transform(map.getProjectionObject(), 'EPSG:4326');
|
||||||
|
lon = p.lon;
|
||||||
|
lat = p.lat;
|
||||||
|
zoom = e.object.zoom;
|
||||||
|
});
|
||||||
|
filling_station_markers = new OpenLayers.Layer.Markers('Filling Stations');
|
||||||
|
map.addLayer(filling_station_markers);
|
||||||
|
|
||||||
|
// handle initial / favourite stations
|
||||||
|
filling_stations = init_stations;
|
||||||
|
update_filling_station_markers();
|
||||||
|
|
||||||
|
update_map();
|
||||||
|
if ((home_lat == 0) && (home_long == 0)) {
|
||||||
|
query_location(update_map);
|
||||||
|
} else {
|
||||||
|
lat = home_lat;
|
||||||
|
lon = home_long;
|
||||||
|
zoom = home_zoom;
|
||||||
|
update_map();
|
||||||
|
}
|
||||||
|
|
||||||
|
// get button
|
||||||
|
$('#'+button_ids[0]).click(function(e){
|
||||||
|
load_filling_stations();
|
||||||
|
});
|
||||||
|
// set home button
|
||||||
|
$('#'+button_ids[1]).click(function(e){
|
||||||
|
$.ajax({
|
||||||
|
type: 'POST',
|
||||||
|
url: '/account/home',
|
||||||
|
data: JSON.stringify({'long': lon, 'lat': lat, 'zoom': zoom}),
|
||||||
|
dataType: 'json',
|
||||||
|
timeout: 1000,
|
||||||
|
contentType : 'application/json'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// go home button
|
||||||
|
$('#'+button_ids[2]).click(function(e){
|
||||||
|
$.ajax({
|
||||||
|
type: 'GET',
|
||||||
|
url: '/account/home',
|
||||||
|
dataType: 'json',
|
||||||
|
timeout: 1000,
|
||||||
|
success: function(data) {
|
||||||
|
lat = data.lat;
|
||||||
|
lon = data.long;
|
||||||
|
zoom = data.zoom;
|
||||||
|
update_map();
|
||||||
|
},
|
||||||
|
contentType : 'application/json'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
4
app/static/js/jquery.tablesorter.min.js
vendored
Normal file
BIN
app/static/logos/agip.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
app/static/logos/aral.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
app/static/logos/avia.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/static/logos/bft.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
app/static/logos/esso.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
app/static/logos/hem.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
app/static/logos/jet.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/static/logos/omv.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
app/static/logos/shell.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
app/static/logos/total.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/static/logos/zg raiffeisen energie.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
11
app/static/modernizr-2.8.3-respond-1.4.2.min.js
vendored
1
app/static/normalize.min.css
vendored
@@ -1 +0,0 @@
|
|||||||
/*! normalize.css v3.0.2 | MIT License | git.io/normalize */html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}input{line-height:normal}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{border:0;padding:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}
|
|
||||||
@@ -63,6 +63,32 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">Filling Stations</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 olMap" style="height: 400px" id="mapdiv"></div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="row">
|
||||||
|
<div class="btn-group col-md-12" role="group">
|
||||||
|
<button type="button" class="btn btn-default glyphicon glyphicon-home" id="go_home_button" />
|
||||||
|
<button type="button" class="btn btn-default glyphicon glyphicon-screenshot " id="set_home_button" />
|
||||||
|
<button type="button" class="btn btn-default glyphicon glyphicon-download" id="get_button" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="station_info" class="row">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var lat = {{ map_pos[0] or 0 }};
|
||||||
|
var long = {{ map_pos[1] or 0 }};
|
||||||
|
var zoom = {{ map_pos[2] or 0 }};
|
||||||
|
var init_filling_station = JSON.parse({{ fs|tojson }});
|
||||||
|
activate_map('mapdiv', ['get_button', 'set_home_button', 'go_home_button'], lat, long, zoom, init_filling_station);
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
<div class="panel panel-default">
|
<div class="panel panel-default">
|
||||||
<div class="panel-heading">Account</div>
|
<div class="panel-heading">Account</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
|||||||
@@ -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.ext_id) }}
|
||||||
{{ render_field_with_errors(form.unit) }}
|
{{ render_field_with_errors(form.unit) }}
|
||||||
{{ render_field_with_errors(form.submit) }}
|
{{ render_field_with_errors(form.submit) }}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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.ext_id) }}
|
||||||
{{ render_field_with_errors(form.unit) }}
|
{{ render_field_with_errors(form.unit) }}
|
||||||
{{ render_field_with_errors(form.submit) }}
|
{{ render_field_with_errors(form.submit) }}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% macro navigation() -%}
|
{% macro navigation() -%}
|
||||||
{% if current_user.email %}
|
{% if current_user.email %}
|
||||||
|
<li><a id='plan_pitstop_link' href='{{ url_for('select_vehicle_for_plan_pitstop') }}'>Plan Pitstop</a></li>
|
||||||
<li><a id='new_pitstop_link' href='{{ url_for('select_vehicle_for_new_pitstop') }}'>Create Pitstop</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='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='statistics_limk' href='{{ url_for('get_statistics') }}'>Statistics</a></li>
|
||||||
@@ -106,19 +107,19 @@
|
|||||||
<title>refuel journal</title>
|
<title>refuel journal</title>
|
||||||
<meta name="description" content="">
|
<meta name="description" content="">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon-57.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/apple-touch-icon-57.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon-60.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/apple-touch-icon-60.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon-72.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/apple-touch-icon-72.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon-76.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/apple-touch-icon-76.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon-114.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/apple-touch-icon-114.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon-120.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/apple-touch-icon-120.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon-144.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/apple-touch-icon-144.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon-152.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/apple-touch-icon-152.png') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='apple-touch-icon-180.png') }}">
|
<link rel="apple-touch-icon" href="{{ url_for('static', filename='img/apple-touch-icon-180.png') }}">
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='android-icon-192x192.png') }}">
|
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='img/android-icon-192x192.png') }}">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='img/favicon-32x32.png') }}">
|
||||||
<link rel="icon" type="image/png" sizes="96x96" href="{{ url_for('static', filename='favicon-96x96.png') }}">
|
<link rel="icon" type="image/png" sizes="96x96" href="{{ url_for('static', filename='img/favicon-96x96.png') }}">
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='img/favicon-16x16.png') }}">
|
||||||
|
|
||||||
<!-- Latest compiled and minified CSS -->
|
<!-- Latest compiled and minified CSS -->
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
|
||||||
@@ -127,13 +128,17 @@
|
|||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap-theme.min.css">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap-theme.min.css">
|
||||||
|
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
|
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/jquery.tablesorter.min.js') }}"></script>
|
||||||
|
|
||||||
<!-- Latest compiled and minified JavaScript -->
|
<!-- Latest compiled and minified JavaScript -->
|
||||||
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
|
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='main.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||||
<script src="https://www.amcharts.com/lib/3/amcharts.js"></script>
|
<script src="https://www.amcharts.com/lib/3/amcharts.js"></script>
|
||||||
<script src="https://www.amcharts.com/lib/3/serial.js"></script>
|
<script src="https://www.amcharts.com/lib/3/serial.js"></script>
|
||||||
<script src="https://www.amcharts.com/lib/3/themes/patterns.js"></script>
|
<script src="https://www.amcharts.com/lib/3/themes/patterns.js"></script>
|
||||||
<script src="{{ url_for('static', filename='main.js') }}"></script>
|
<script src="https://openlayers.org/api/OpenLayers.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/fillingstations.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-inverse navbar-fixed-top">
|
<nav class="navbar navbar-inverse navbar-fixed-top">
|
||||||
|
|||||||
57
app/templates/planPitStopForm.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% 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>Plan Pitstop for '{{ vehicle.name }}'</h3>
|
||||||
|
Price comparision for {{ consumable.name }}:
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="compare" class="table table-striped table-bordered table-condensed tablesorter">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Filling Station</th>
|
||||||
|
<th>Price/{{ consumable.unit }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for offer in offers %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="row filling_station_info " style="border: 0px">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div>{{ offer[0].name }}</div>
|
||||||
|
<div>{{ offer[0].street }} {{ offer[0].houseNumber }}</div>
|
||||||
|
<div>{{ offer[0].postCode }} {{ offer[0].place }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4" style="height: 60px;">
|
||||||
|
<img src="/static/logos/{{ offer[0].brand|lower }}.png">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if offer[0].open %}
|
||||||
|
{{ offer[1] }} €/{{ consumable.unit }}
|
||||||
|
{% else %}
|
||||||
|
Closed
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$("#compare").tablesorter({sortList: [[1,0]]});
|
||||||
|
$("img").error(function(){
|
||||||
|
$(this).hide();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<div class="col-md-2" ></div>
|
||||||
|
{% endblock %}
|
||||||
99
app/tools.py
@@ -1,7 +1,10 @@
|
|||||||
|
from sqlalchemy import or_
|
||||||
|
import requests
|
||||||
import logging
|
import logging
|
||||||
from datetime import date
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
from .entities import Pitstop
|
from .entities import Pitstop, FillingStation
|
||||||
|
from . import db, app
|
||||||
|
|
||||||
|
|
||||||
class ConsumableStats:
|
class ConsumableStats:
|
||||||
@@ -34,7 +37,7 @@ class ConsumableStats:
|
|||||||
self.average_amount.append(
|
self.average_amount.append(
|
||||||
StatsEvent(
|
StatsEvent(
|
||||||
current_ps.date,
|
current_ps.date,
|
||||||
round(100 * current_ps.amount/(current_ps.odometer - last_ps.odometer), 2)))
|
round(100 * current_ps.amount / (current_ps.odometer - last_ps.odometer), 2)))
|
||||||
|
|
||||||
|
|
||||||
class VehicleStats:
|
class VehicleStats:
|
||||||
@@ -104,9 +107,9 @@ def get_latest_pitstop_for_vehicle(vehicle_id):
|
|||||||
:param vehicle_id: the id of the vehicle
|
:param vehicle_id: the id of the vehicle
|
||||||
:return: the latest pitstop or None if no pitstop exists
|
:return: the latest pitstop or None if no pitstop exists
|
||||||
"""
|
"""
|
||||||
latest_pitstop = Pitstop.query\
|
latest_pitstop = Pitstop.query \
|
||||||
.filter(Pitstop.vehicle_id == vehicle_id)\
|
.filter(Pitstop.vehicle_id == vehicle_id) \
|
||||||
.order_by(Pitstop.id.desc())\
|
.order_by(Pitstop.id.desc()) \
|
||||||
.first()
|
.first()
|
||||||
return latest_pitstop
|
return latest_pitstop
|
||||||
|
|
||||||
@@ -118,10 +121,10 @@ def get_latest_pitstop_for_vehicle_and_consumable(vehicle_id, consumable_id):
|
|||||||
:param consumable_id: the id of the consumable
|
:param consumable_id: the id of the consumable
|
||||||
:return: the latest pitstop or None if no pitstop exists
|
:return: the latest pitstop or None if no pitstop exists
|
||||||
"""
|
"""
|
||||||
latest_pitstop_consumable = Pitstop.query\
|
latest_pitstop_consumable = Pitstop.query \
|
||||||
.filter(Pitstop.vehicle_id == vehicle_id)\
|
.filter(Pitstop.vehicle_id == vehicle_id) \
|
||||||
.filter(Pitstop.consumable_id == consumable_id)\
|
.filter(Pitstop.consumable_id == consumable_id) \
|
||||||
.order_by(Pitstop.id.desc())\
|
.order_by(Pitstop.id.desc()) \
|
||||||
.first()
|
.first()
|
||||||
return latest_pitstop_consumable
|
return latest_pitstop_consumable
|
||||||
|
|
||||||
@@ -150,33 +153,6 @@ def compute_lower_limits_for_new_pitstop(latest_pitstop, last_pitstop_consumable
|
|||||||
return Pitstop(odometer, amount, date_of_pitstop, costs, consumable_id)
|
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):
|
def pitstop_service_key(x):
|
||||||
return x.odometer, x.date
|
return x.odometer, x.date
|
||||||
|
|
||||||
@@ -188,4 +164,51 @@ def get_event_line_for_vehicle(vehicle):
|
|||||||
for service in vehicle.services:
|
for service in vehicle.services:
|
||||||
data.append(service)
|
data.append(service)
|
||||||
data.sort(key=pitstop_service_key)
|
data.sort(key=pitstop_service_key)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def chunks(l, n):
|
||||||
|
"""Yield successive n-sized chunks from l."""
|
||||||
|
for i in range(0, len(l), n):
|
||||||
|
yield l[i:i + n]
|
||||||
|
|
||||||
|
|
||||||
|
def update_filling_station_prices(ids):
|
||||||
|
max_age = (datetime.now() - timedelta(minutes=15)).strftime('%Y-%m-%d %H:%M')
|
||||||
|
|
||||||
|
res = db.session. \
|
||||||
|
query(FillingStation). \
|
||||||
|
filter(FillingStation.id.in_(ids)). \
|
||||||
|
filter(or_(FillingStation.last_update == None, FillingStation.last_update < max_age)). \
|
||||||
|
all()
|
||||||
|
|
||||||
|
if len(res) > 0:
|
||||||
|
id_map = {x.id: x for x in res}
|
||||||
|
query_ids = [x.id for x in res]
|
||||||
|
api_key = app.config['TANKERKOENIG_API_KEY']
|
||||||
|
url = 'https://creativecommons.tankerkoenig.de/json/prices.php'
|
||||||
|
|
||||||
|
# documentation tells us to query max 10 filling stations at a time...
|
||||||
|
for c in chunks(query_ids, 10):
|
||||||
|
params = {
|
||||||
|
'apikey': api_key, 'ids': ','.join(c)
|
||||||
|
}
|
||||||
|
response = requests.get(url, params=params)
|
||||||
|
response_json = response.json()
|
||||||
|
if response_json['ok']:
|
||||||
|
print(response_json)
|
||||||
|
prices = response_json['prices']
|
||||||
|
for price in prices:
|
||||||
|
id = price
|
||||||
|
station_status = prices[id]
|
||||||
|
id_map[id].open = station_status['status'] == 'open'
|
||||||
|
if id_map[id].open:
|
||||||
|
id_map[id].diesel = station_status['diesel']
|
||||||
|
id_map[id].e10 = station_status['e10']
|
||||||
|
id_map[id].e5 = station_status['e5']
|
||||||
|
id_map[id].last_update = datetime.now()
|
||||||
|
else:
|
||||||
|
logging.error(
|
||||||
|
'could not update filling stations because of {r} on URL {u}.'.format(r=str(response_json),
|
||||||
|
u=response.url))
|
||||||
|
db.session.commit()
|
||||||
|
|||||||
16
config.py
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
basedir = os.path.abspath(os.path.dirname(__file__))
|
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ class Config:
|
|||||||
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')
|
||||||
ADMIN_MAIL = 'joachim@lusiardi.de'
|
ADMIN_MAIL = 'joachim@lusiardi.de'
|
||||||
|
TANKERKOENIG_API_KEY = os.environ.get('TANKERKOENIG_API_KEY')
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def init_app(app):
|
def init_app(app):
|
||||||
@@ -27,16 +30,19 @@ 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 = 'mysql+pymysql://root:%s@database/pitstops' % (os.environ.get('DATABASE_ENV_MYSQL_ROOT_PASSWORD'))
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data.sqlite')
|
||||||
|
|
||||||
|
|
||||||
class TestingConfig(Config):
|
class TestingConfig(Config):
|
||||||
TESTING = True
|
SECURITY_SEND_REGISTER_EMAIL = False
|
||||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data_testing.sqlite')
|
DEBUG = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:{p}@{h}/pitstops'.format(p=os.environ.get('MYSQL_PASSWORD'),
|
||||||
|
h=os.environ.get('MYSQL_HOST'))
|
||||||
|
|
||||||
|
|
||||||
class ProductionConfig(Config):
|
class ProductionConfig(Config):
|
||||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data.sqlite')
|
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:{h}@database/pitstops'.format(
|
||||||
|
h=os.environ.get('DATABASE_ENV_MYSQL_ROOT_PASSWORD'))
|
||||||
|
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
@@ -44,4 +50,4 @@ config = {
|
|||||||
'testing': TestingConfig,
|
'testing': TestingConfig,
|
||||||
'production': ProductionConfig,
|
'production': ProductionConfig,
|
||||||
'default': DevelopmentConfig
|
'default': DevelopmentConfig
|
||||||
}
|
}
|
||||||
|
|||||||
39
database_upgrades/upgrade_gillian_to_henni.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
CREATE TABLE `filling_station` (
|
||||||
|
`int_id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`id` varchar(40) NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`street` text NOT NULL,
|
||||||
|
`place` text NOT NULL,
|
||||||
|
`houseNumber` text,
|
||||||
|
`postCode` int(11) NOT NULL,
|
||||||
|
`brand` text NOT NULL,
|
||||||
|
`lat` decimal(8,5) NOT NULL,
|
||||||
|
`lng` decimal(8,5) NOT NULL,
|
||||||
|
`last_update` datetime DEFAULT NULL,
|
||||||
|
`diesel` decimal(10,3) DEFAULT NULL,
|
||||||
|
`e5` decimal(10,3) DEFAULT NULL,
|
||||||
|
`e10` decimal(10,3) DEFAULT NULL,
|
||||||
|
`open` tinyint(1) DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`int_id`),
|
||||||
|
UNIQUE KEY `id` (`id`)
|
||||||
|
) ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=latin1;
|
||||||
|
|
||||||
|
CREATE TABLE `users_fillingstations` (
|
||||||
|
`user_id` int(11) DEFAULT NULL,
|
||||||
|
`fillingstation_id` int(11) DEFAULT NULL,
|
||||||
|
KEY `user_id` (`user_id`),
|
||||||
|
KEY `fillingstation_id` (`fillingstation_id`),
|
||||||
|
CONSTRAINT `users_fillingstations_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
|
||||||
|
CONSTRAINT `users_fillingstations_ibfk_2` FOREIGN KEY (`fillingstation_id`) REFERENCES `filling_station` (`int_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
|
||||||
|
|
||||||
|
ALTER TABLE `consumable` ADD COLUMN `ext_id` varchar(255) DEFAULT NULL;
|
||||||
|
update consumable set ext_id = 'e5' where id = 1;
|
||||||
|
update consumable set ext_id = 'e10' where id = 34;
|
||||||
|
update consumable set ext_id = 'diesel' where id = 32;
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE `user` ADD COLUMN `home_lat` decimal(8,5) DEFAULT NULL;
|
||||||
|
ALTER TABLE `user` ADD COLUMN `home_long` decimal(8,5) DEFAULT NULL;
|
||||||
|
ALTER TABLE `user` ADD COLUMN `home_zoom` int(11) DEFAULT NULL;
|
||||||
|
|
||||||
7
main.py
@@ -1,12 +1,17 @@
|
|||||||
import os
|
import os
|
||||||
from app import app
|
from app import app
|
||||||
import logging
|
import logging
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging():
|
||||||
logging.basicConfig(format='%(asctime)s [%(levelname)s]: %(message)s', level=logging.INFO)
|
logging.basicConfig(format='%(asctime)s [%(levelname)s]: %(message)s', level=logging.INFO)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
DEBUG = 'DEBUG' in os.environ and os.environ['DEBUG'] != 'False'
|
c = config[os.getenv('FLASK_CONFIG') or 'default']
|
||||||
|
|
||||||
|
DEBUG = c.DEBUG
|
||||||
|
DEBUG = os.environ.get('DEBUG', DEBUG)
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
app.run(debug=DEBUG, host='0.0.0.0')
|
app.run(debug=DEBUG, host='0.0.0.0')
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ Flask-Security
|
|||||||
Flask-WTF
|
Flask-WTF
|
||||||
PyMySQL
|
PyMySQL
|
||||||
markdown
|
markdown
|
||||||
|
Flask-Limiter
|
||||||
|
requests
|
||||||