Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ def format_date_filter(value, style='default'):
fmt = formats.get(style, formats['default'])
return value.strftime(fmt)

from app.routes import main, auth, vehicles, fuel, expenses, api, reminders, maintenance, documents, stations, recurring, homeassistant, calendar, trips, charging
from app.routes import main, auth, vehicles, fuel, expenses, api, reminders, maintenance, documents, stations, recurring, homeassistant, calendar, trips, charging, notes, allowance
app.register_blueprint(main.bp)
app.register_blueprint(auth.bp)
app.register_blueprint(vehicles.bp)
Expand All @@ -340,6 +340,8 @@ def format_date_filter(value, style='default'):
app.register_blueprint(calendar.bp)
app.register_blueprint(trips.bp)
app.register_blueprint(charging.bp)
app.register_blueprint(notes.bp)
app.register_blueprint(allowance.bp)

# Health check endpoint for container orchestration
@app.route('/health')
Expand Down
84 changes: 84 additions & 0 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ class User(UserMixin, db.Model):
show_menu_stations = db.Column(db.Boolean, default=True)
show_menu_trips = db.Column(db.Boolean, default=True)
show_menu_charging = db.Column(db.Boolean, default=True)
show_menu_notes = db.Column(db.Boolean, default=True) # issue #204
show_menu_allowance = db.Column(db.Boolean, default=True) # issue #208
show_quick_entry = db.Column(db.Boolean, default=False) # Show quick entry button in navbar

# Relationships
Expand Down Expand Up @@ -254,6 +256,14 @@ def get_total_expense_cost(self):
def get_total_cost(self):
return self.get_total_fuel_cost() + self.get_total_expense_cost() + self.get_total_charging_cost()

def get_total_allowance(self):
"""Total mileage-allowance income recorded for this vehicle (issue #208)."""
return sum(a.amount for a in self.mileage_allowances.all() if a.amount) or 0

def get_net_cost(self):
"""Running cost after mileage allowance is deducted (issue #208)."""
return self.get_total_cost() - self.get_total_allowance()

@property
def vehicle_type_label(self):
return dict(VEHICLE_TYPES).get(self.vehicle_type, self.vehicle_type.replace('_', ' ').title())
Expand Down Expand Up @@ -582,6 +592,7 @@ class FuelLog(db.Model):
odometer = db.Column(db.Float, nullable=False) # stored in km
volume = db.Column(db.Float) # stored in liters
price_per_unit = db.Column(db.Float) # price per liter
discount_per_unit = db.Column(db.Float) # optional loyalty discount per liter (issue #209)
total_cost = db.Column(db.Float)

fuel_type = db.Column(db.String(20), nullable=True) # overrides vehicle primary; set when vehicle has secondary fuel type
Expand Down Expand Up @@ -656,6 +667,7 @@ def to_dict(self, consumption_unit=None, volume_unit='L'):
'odometer': self.odometer,
'volume': self.volume,
'price_per_unit': self.price_per_unit,
'discount_per_unit': self.discount_per_unit,
'total_cost': self.total_cost,
'is_full_tank': self.is_full_tank,
'is_missed': self.is_missed,
Expand Down Expand Up @@ -1462,3 +1474,75 @@ class FuelPriceHistory(db.Model):
# Relationships
station = db.relationship('FuelStation', backref=db.backref('price_history', lazy='dynamic'))
user = db.relationship('User', backref=db.backref('fuel_price_history', lazy='dynamic'))


class Note(db.Model):
"""Freeform note attached to a vehicle, with optional odometer reading.

Issue #204: a place to record things that don't fit fuel/expenses/maintenance
(e.g. a DPF regeneration) without inventing a cost.
"""
__tablename__ = 'notes'

id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)

date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
title = db.Column(db.String(200))
content = db.Column(db.Text, nullable=False)
odometer = db.Column(db.Float) # optional, stored in vehicle odometer unit

created_at = db.Column(db.DateTime, default=datetime.utcnow)

# Relationships — backref is `note_entries` to avoid clashing with Vehicle.notes column
vehicle = db.relationship('Vehicle', backref=db.backref('note_entries', lazy='dynamic', cascade='all, delete-orphan'))
user = db.relationship('User', backref=db.backref('notes', lazy='dynamic'))

def to_dict(self):
return {
'id': self.id,
'vehicle_id': self.vehicle_id,
'date': self.date.isoformat() if self.date else None,
'title': self.title,
'content': self.content,
'odometer': self.odometer,
'created_at': self.created_at.isoformat() if self.created_at else None,
}


class MileageAllowance(db.Model):
"""Mileage-allowance income for a vehicle used for business (issue #208).

Records money received per the recorded distance; the totals offset the
vehicle's running costs (see Vehicle.get_net_cost).
"""
__tablename__ = 'mileage_allowances'

id = db.Column(db.Integer, primary_key=True)
vehicle_id = db.Column(db.Integer, db.ForeignKey('vehicles.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)

date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
description = db.Column(db.String(200))
distance = db.Column(db.Float) # optional, stored in vehicle odometer unit
rate_per_unit = db.Column(db.Float) # optional reimbursement rate per distance unit
amount = db.Column(db.Float, nullable=False) # total amount received

created_at = db.Column(db.DateTime, default=datetime.utcnow)

# Relationships
vehicle = db.relationship('Vehicle', backref=db.backref('mileage_allowances', lazy='dynamic', cascade='all, delete-orphan'))
user = db.relationship('User', backref=db.backref('mileage_allowances', lazy='dynamic'))

def to_dict(self):
return {
'id': self.id,
'vehicle_id': self.vehicle_id,
'date': self.date.isoformat() if self.date else None,
'description': self.description,
'distance': self.distance,
'rate_per_unit': self.rate_per_unit,
'amount': self.amount,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
116 changes: 116 additions & 0 deletions app/routes/allowance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
from datetime import datetime
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from flask_babel import gettext as _
from app import db
from app.models import Vehicle, MileageAllowance

bp = Blueprint('allowance', __name__, url_prefix='/allowance')


@bp.route('/')
@login_required
def index():
vehicles = current_user.get_all_vehicles()
vehicle_ids = [v.id for v in vehicles]

allowances = MileageAllowance.query.filter(
MileageAllowance.vehicle_id.in_(vehicle_ids)
).order_by(MileageAllowance.date.desc()).all()

return render_template('allowance/index.html', allowances=allowances, vehicles=vehicles)


@bp.route('/new', methods=['GET', 'POST'])
@login_required
def new():
vehicles = current_user.get_all_vehicles()

if not vehicles:
flash(_('Please add a vehicle first'), 'info')
return redirect(url_for('vehicles.new'))

if request.method == 'POST':
vehicle_id = int(request.form.get('vehicle_id'))
vehicle = Vehicle.query.get_or_404(vehicle_id)

# Check access
if vehicle not in vehicles:
flash(_('Access denied'), 'error')
return redirect(url_for('allowance.index'))

try:
date_str = request.form.get('date')
allowance = MileageAllowance(
vehicle_id=vehicle_id,
user_id=current_user.id,
date=datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else datetime.now().date(),
description=request.form.get('description') or None,
distance=float(request.form.get('distance')) if request.form.get('distance') else None,
rate_per_unit=float(request.form.get('rate_per_unit')) if request.form.get('rate_per_unit') else None,
amount=float(request.form.get('amount')),
)
except (ValueError, TypeError):
flash(_('Invalid data submitted. Please check the date and amount fields.'), 'error')
return render_template('allowance/form.html', allowance=None, vehicles=vehicles,
selected_vehicle_id=vehicle_id)

db.session.add(allowance)
db.session.commit()
flash(_('Mileage allowance added successfully'), 'success')
return redirect(url_for('vehicles.view', vehicle_id=vehicle_id))

selected_vehicle_id = request.args.get('vehicle_id', type=int) or current_user.default_vehicle_id

return render_template('allowance/form.html', allowance=None, vehicles=vehicles,
selected_vehicle_id=selected_vehicle_id)


@bp.route('/<int:allowance_id>/edit', methods=['GET', 'POST'])
@login_required
def edit(allowance_id):
allowance = MileageAllowance.query.get_or_404(allowance_id)
vehicles = current_user.get_all_vehicles()

# Check access
if allowance.vehicle not in vehicles:
flash(_('Access denied'), 'error')
return redirect(url_for('allowance.index'))

if request.method == 'POST':
try:
date_str = request.form.get('date')
allowance.date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else allowance.date
allowance.description = request.form.get('description') or None
allowance.distance = float(request.form.get('distance')) if request.form.get('distance') else None
allowance.rate_per_unit = float(request.form.get('rate_per_unit')) if request.form.get('rate_per_unit') else None
allowance.amount = float(request.form.get('amount'))
except (ValueError, TypeError):
flash(_('Invalid data submitted. Please check the date and amount fields.'), 'error')
return render_template('allowance/form.html', allowance=allowance, vehicles=vehicles,
selected_vehicle_id=allowance.vehicle_id)

db.session.commit()
flash(_('Mileage allowance updated successfully'), 'success')
return redirect(url_for('vehicles.view', vehicle_id=allowance.vehicle_id))

return render_template('allowance/form.html', allowance=allowance, vehicles=vehicles,
selected_vehicle_id=allowance.vehicle_id)


@bp.route('/<int:allowance_id>/delete', methods=['POST'])
@login_required
def delete(allowance_id):
allowance = MileageAllowance.query.get_or_404(allowance_id)
vehicles = current_user.get_all_vehicles()

# Check access
if allowance.vehicle not in vehicles:
flash(_('Access denied'), 'error')
return redirect(url_for('allowance.index'))

vehicle_id = allowance.vehicle_id
db.session.delete(allowance)
db.session.commit()
flash(_('Mileage allowance deleted successfully'), 'success')
return redirect(url_for('vehicles.view', vehicle_id=vehicle_id))
4 changes: 4 additions & 0 deletions app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ def get_start_page_url(user):
'stations': 'stations.index',
'trips': 'trips.index',
'charging': 'charging.index',
'notes': 'notes.index',
'allowance': 'allowance.index',
}
route = page_routes.get(start_page, 'main.dashboard')
return url_for(route)
Expand Down Expand Up @@ -352,6 +354,8 @@ def menu_preferences():
current_user.show_menu_stations = request.form.get('show_menu_stations') == 'on'
current_user.show_menu_trips = request.form.get('show_menu_trips') == 'on'
current_user.show_menu_charging = request.form.get('show_menu_charging') == 'on'
current_user.show_menu_notes = request.form.get('show_menu_notes') == 'on'
current_user.show_menu_allowance = request.form.get('show_menu_allowance') == 'on'
current_user.show_quick_entry = request.form.get('show_quick_entry') == 'on'
db.session.commit()
flash(_('Menu preferences updated'), 'success')
Expand Down
19 changes: 15 additions & 4 deletions app/routes/fuel.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ def new():
flash(err, 'error')
return render_template('fuel/new.html', vehicles=vehicles)

discount_per_unit = None
if request.form.get('discount_per_unit'):
discount_per_unit, err = validate_positive_number(request.form.get('discount_per_unit'), 'Discount per unit', max_value=1000)
if err:
flash(err, 'error')
return render_template('fuel/new.html', vehicles=vehicles)

total_cost, err = validate_positive_number(request.form.get('total_cost'), 'Total cost', max_value=100000)
if err:
flash(err, 'error')
Expand All @@ -92,6 +99,7 @@ def new():
odometer=odometer,
volume=volume,
price_per_unit=price_per_unit,
discount_per_unit=discount_per_unit,
total_cost=total_cost,
fuel_type=request.form.get('fuel_type') or None,
is_full_tank=request.form.get('is_full_tank') == 'on',
Expand All @@ -100,9 +108,10 @@ def new():
notes=request.form.get('notes')
)

# Calculate total cost if not provided
# Calculate total cost if not provided, applying any per-unit discount (#209)
if log.volume and log.price_per_unit and not log.total_cost:
log.total_cost = round(log.volume * log.price_per_unit, 2)
effective_price = log.price_per_unit - (log.discount_per_unit or 0)
log.total_cost = round(log.volume * effective_price, 2)

db.session.add(log)
db.session.commit()
Expand Down Expand Up @@ -186,16 +195,18 @@ def edit(log_id):
log.odometer = float(request.form.get('odometer'))
log.volume = float(request.form.get('volume')) if request.form.get('volume') else None
log.price_per_unit = float(request.form.get('price_per_unit')) if request.form.get('price_per_unit') else None
log.discount_per_unit = float(request.form.get('discount_per_unit')) if request.form.get('discount_per_unit') else None
log.total_cost = float(request.form.get('total_cost')) if request.form.get('total_cost') else None
log.fuel_type = request.form.get('fuel_type') or None
log.is_full_tank = request.form.get('is_full_tank') == 'on'
log.is_missed = request.form.get('is_missed') == 'on'
log.station = request.form.get('station')
log.notes = request.form.get('notes')

# Calculate total cost if not provided
# Calculate total cost if not provided, applying any per-unit discount (#209)
if log.volume and log.price_per_unit and not log.total_cost:
log.total_cost = round(log.volume * log.price_per_unit, 2)
effective_price = log.price_per_unit - (log.discount_per_unit or 0)
log.total_cost = round(log.volume * effective_price, 2)

# Reconcile fuel price history with the edited log.
# Issue #170: linking a station to an existing log via edit must
Expand Down
15 changes: 14 additions & 1 deletion app/routes/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime, timedelta
from sqlalchemy import func
from app import db
from app.models import Vehicle, FuelLog, Expense, ChargingSession, FuelPriceHistory, FuelStation
from app.models import Vehicle, FuelLog, Expense, ChargingSession, FuelPriceHistory, FuelStation, MileageAllowance

bp = Blueprint('main', __name__)

Expand All @@ -26,6 +26,8 @@ def index():
'stations': 'stations.index',
'trips': 'trips.index',
'charging': 'charging.index',
'notes': 'notes.index',
'allowance': 'allowance.index',
}
route = page_routes.get(start_page, 'main.dashboard')
return redirect(url_for(route))
Expand Down Expand Up @@ -97,8 +99,17 @@ def dashboard():
).scalar()
total_charging_cost = charging_result or 0

# Total mileage allowance received (#208) — offsets running costs
total_allowance = 0
if vehicle_ids:
allowance_result = db.session.query(func.sum(MileageAllowance.amount)).filter(
MileageAllowance.vehicle_id.in_(vehicle_ids)
).scalar()
total_allowance = allowance_result or 0

# Calculate cost per distance
total_cost = total_fuel_cost + total_expense_cost + total_charging_cost
net_cost = total_cost - total_allowance
cost_per_distance = None
if total_distance > 0:
cost_per_distance = total_cost / total_distance
Expand All @@ -124,6 +135,8 @@ def dashboard():
total_fuel_cost=total_fuel_cost,
total_expense_cost=total_expense_cost,
total_charging_cost=total_charging_cost,
total_allowance=total_allowance,
net_cost=net_cost,
total_distance=total_distance,
cost_per_distance=cost_per_distance,
recent_logs=recent_logs,
Expand Down
Loading
Loading