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
81 changes: 53 additions & 28 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,18 +285,29 @@ def get_total_distance(self, distance_unit=None):
return logs[-1].odometer - logs[0].odometer

def get_average_consumption(self, consumption_unit=None, volume_unit='L'):
"""Calculate average fuel consumption.
"""Calculate average fuel consumption between the first and last
full-tank fill-ups.

Args:
consumption_unit: 'L/100km', 'mpg', or 'mpg_us'. If None, returns L/100km.
volume_unit: 'L', 'gal' (UK), or 'us_gal'. Used to convert volume for MPG.
Sums every litre poured between those two anchors so partial fills
in the middle are counted (issue #169). Returns None when any log
in the range is flagged ``is_missed`` — we have no way to make the
figure honest in that case.
"""
logs = self.fuel_logs.filter_by(is_full_tank=True).order_by(FuelLog.odometer).all()
if len(logs) < 2:
full_logs = self.fuel_logs.filter_by(is_full_tank=True).order_by(FuelLog.odometer).all()
if len(full_logs) < 2:
return None

first_odo = full_logs[0].odometer
last_odo = full_logs[-1].odometer
range_logs = self.fuel_logs.filter(
FuelLog.odometer > first_odo,
FuelLog.odometer <= last_odo,
).all()
if any(log.is_missed for log in range_logs):
return None

total_fuel = sum(log.volume for log in logs[1:] if log.volume)
total_distance = logs[-1].odometer - logs[0].odometer
total_fuel = sum(log.volume for log in range_logs if log.volume)
total_distance = last_odo - first_odo

if total_distance > 0 and total_fuel > 0:
if consumption_unit == 'mpg':
Expand Down Expand Up @@ -533,41 +544,55 @@ class FuelLog(db.Model):
def get_consumption(self, consumption_unit=None, volume_unit='L'):
"""Calculate consumption for this fill-up.

For full-tank fill-ups, uses the previous full-tank as the baseline so
the figure accounts for a full tank-to-tank cycle. For partial fills,
uses the immediately preceding log (any type) as the baseline to give
an instantaneous estimate for that segment.
For full-tank fill-ups, sum every litre poured between the previous
full tank and this one (inclusive) and divide by the distance covered
— the "fill-to-fill" method. Partial fills between two full tanks are
therefore counted (issue #169). If any of the intervening logs is
flagged ``is_missed``, the figure is unknowable and we return None.

Args:
consumption_unit: 'L/100km', 'mpg', or 'mpg_us'. If None, returns L/100km.
volume_unit: 'L', 'gal' (UK), or 'us_gal'. Used to convert volume for MPG.
For partial fills, compare against the immediately preceding log of
any type to give an instantaneous "fuel added per km" estimate; this
is a rough indicator, not a true consumption figure.
"""
if not self.volume:
return None

if self.is_full_tank:
prev_log = FuelLog.query.filter(
prev_full = FuelLog.query.filter(
FuelLog.vehicle_id == self.vehicle_id,
FuelLog.odometer < self.odometer,
FuelLog.is_full_tank == True
FuelLog.is_full_tank == True,
).order_by(FuelLog.odometer.desc()).first()
if not prev_full:
return None
distance = self.odometer - prev_full.odometer
between = FuelLog.query.filter(
FuelLog.vehicle_id == self.vehicle_id,
FuelLog.odometer > prev_full.odometer,
FuelLog.odometer <= self.odometer,
).all()
if any(log.is_missed for log in between):
return None
volume_native = sum(log.volume for log in between if log.volume)
else:
prev_log = FuelLog.query.filter(
FuelLog.vehicle_id == self.vehicle_id,
FuelLog.odometer < self.odometer,
).order_by(FuelLog.odometer.desc()).first()

if prev_log:
if not prev_log:
return None
distance = self.odometer - prev_log.odometer
if distance > 0 and self.volume > 0:
if consumption_unit == 'mpg':
gallons = _to_uk_gallons(self.volume, volume_unit)
return distance / gallons if gallons > 0 else None
if consumption_unit == 'mpg_us':
gallons = _to_us_gallons(self.volume, volume_unit)
return distance / gallons if gallons > 0 else None
litres = _to_litres(self.volume, volume_unit)
return (litres / distance) * 100 # L/100km
volume_native = self.volume

if distance > 0 and volume_native > 0:
if consumption_unit == 'mpg':
gallons = _to_uk_gallons(volume_native, volume_unit)
return distance / gallons if gallons > 0 else None
if consumption_unit == 'mpg_us':
gallons = _to_us_gallons(volume_native, volume_unit)
return distance / gallons if gallons > 0 else None
litres = _to_litres(volume_native, volume_unit)
return (litres / distance) * 100 # L/100km
return None

def to_dict(self, consumption_unit=None, volume_unit='L'):
Expand Down
5 changes: 3 additions & 2 deletions app/routes/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,9 @@ def calendar_feed(user):

summary = f"🔧 {schedule.name} - {vehicle_name}"
description = f"Maintenance due for {vehicle_name}"
if schedule.next_due_mileage:
description += f"\\nDue at: {schedule.next_due_mileage} {vehicle.unit_distance if vehicle else 'km'}"
if schedule.next_due_odometer:
unit = vehicle.get_effective_odometer_unit() if vehicle else 'km'
description += f"\\nDue at: {schedule.next_due_odometer:.0f} {unit}"
if schedule.notes:
description += f"\\nNotes: {schedule.notes}"

Expand Down
51 changes: 30 additions & 21 deletions app/routes/fuel.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,32 +188,41 @@ def edit(log_id):
if log.volume and log.price_per_unit and not log.total_cost:
log.total_cost = round(log.volume * log.price_per_unit, 2)

# Keep fuel price history in sync with the edited log
# Reconcile fuel price history with the edited log.
# Issue #170: linking a station to an existing log via edit must
# both create the price-history row (so it shows in "cheapest fuel")
# and bump the station's `times_used` counter.
station_id = request.form.get('station_id', type=int)
existing_entry = None
if old_price and old_date:
history_entry = FuelPriceHistory.query.filter_by(
existing_entry = FuelPriceHistory.query.filter_by(
user_id=current_user.id,
date=old_date,
price_per_unit=old_price
price_per_unit=old_price,
).first()
if history_entry:
if log.price_per_unit:
history_entry.price_per_unit = log.price_per_unit
history_entry.date = log.date
else:
db.session.delete(history_entry)

if existing_entry:
if not log.price_per_unit:
db.session.delete(existing_entry)
else:
# No existing history entry — create one if a station is now selected
station_id = request.form.get('station_id', type=int)
if station_id and log.price_per_unit:
station = FuelStation.query.get(station_id)
if station:
db.session.add(FuelPriceHistory(
station_id=station_id,
user_id=current_user.id,
date=log.date,
fuel_type=log.fuel_type or log.vehicle.fuel_type or 'petrol',
price_per_unit=log.price_per_unit
))
existing_entry.price_per_unit = log.price_per_unit
existing_entry.date = log.date
if station_id and existing_entry.station_id != station_id:
new_station = FuelStation.query.get(station_id)
if new_station:
existing_entry.station_id = station_id
new_station.increment_usage()
elif station_id and log.price_per_unit:
new_station = FuelStation.query.get(station_id)
if new_station:
db.session.add(FuelPriceHistory(
station_id=station_id,
user_id=current_user.id,
date=log.date,
fuel_type=log.fuel_type or log.vehicle.fuel_type or 'petrol',
price_per_unit=log.price_per_unit,
))
new_station.increment_usage()

# Handle attachment upload
if 'attachment' in request.files:
Expand Down
2 changes: 1 addition & 1 deletion app/routes/homeassistant.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ def alerts(user):
'vehicle': schedule.vehicle.name,
'title': schedule.name,
'status': 'overdue' if schedule.is_overdue() else 'due',
'due_mileage': schedule.next_due_mileage,
'due_odometer': schedule.next_due_odometer,
'due_date': schedule.next_due_date.isoformat() if schedule.next_due_date else None
})

Expand Down
2 changes: 1 addition & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
basedir = Path(__file__).parent.absolute()


APP_VERSION = '0.22.1'
APP_VERSION = '0.22.2'
RELEASE_CHANNEL = os.environ.get('RELEASE_CHANNEL', 'stable')
GIT_SHA = os.environ.get('GIT_SHA', '')[:7] # Short SHA
GITHUB_REPO = 'dannymcc/may'
Expand Down
100 changes: 100 additions & 0 deletions tests/test_fuel.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,67 @@ def test_partial_fill_uses_any_previous_log(self, app, test_user, sample_vehicle
assert consumption is not None
assert abs(consumption - 10.0) < 0.01

def test_full_tank_sums_intervening_partials(self, app, test_user, sample_vehicle):
"""#169 — full tank consumption must sum partial fills since the previous full."""
# Astrmn's reported scenario: full → partial → partial → full,
# 1371 km between fulls, 19.67 + 12.71 + 53.80 = 86.18 L total.
log_first_full = FuelLog(
vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 21), odometer=10000, volume=50, is_full_tank=True,
)
partial_a = FuelLog(
vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 24), odometer=10500, volume=19.67, is_full_tank=False,
)
partial_b = FuelLog(
vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 27), odometer=10900, volume=12.71, is_full_tank=False,
)
log_last_full = FuelLog(
vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 29), odometer=11371, volume=53.80, is_full_tank=True,
)
db.session.add_all([log_first_full, partial_a, partial_b, log_last_full])
db.session.commit()
# Fill-to-fill: (19.67 + 12.71 + 53.80) / 1371 * 100 = 6.286 L/100km
consumption = log_last_full.get_consumption()
assert consumption is not None
assert abs(consumption - 6.286) < 0.01

def test_full_tank_returns_none_when_intervening_log_is_missed(
self, app, test_user, sample_vehicle):
"""A missed fill in the range invalidates the consumption figure."""
log1 = FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 1), odometer=10000, volume=40, is_full_tank=True)
missed = FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 5), odometer=10300, volume=20,
is_full_tank=False, is_missed=True)
log3 = FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 10), odometer=10500, volume=42,
is_full_tank=True)
db.session.add_all([log1, missed, log3])
db.session.commit()
assert log3.get_consumption() is None

def test_average_consumption_includes_partial_fills(
self, app, test_user, sample_vehicle):
"""#169 — vehicle average must count partial fills between two fulls."""
logs = [
FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 21), odometer=10000, volume=50, is_full_tank=True),
FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 24), odometer=10500, volume=19.67, is_full_tank=False),
FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 27), odometer=10900, volume=12.71, is_full_tank=False),
FuelLog(vehicle_id=sample_vehicle.id, user_id=test_user.id,
date=date(2026, 4, 29), odometer=11371, volume=53.80, is_full_tank=True),
]
db.session.add_all(logs)
db.session.commit()
avg = sample_vehicle.get_average_consumption()
assert avg is not None
assert abs(avg - 6.286) < 0.01


@pytest.fixture
def sample_station(app, test_user):
Expand Down Expand Up @@ -252,6 +313,45 @@ def test_stale_price_not_shown_after_edit(self, auth_client, fuel_log_with_price
db.session.refresh(history)
assert history.price_per_unit == 2.547

def test_edit_links_station_to_existing_log(
self, auth_client, test_user, sample_vehicle, sample_station):
"""#170 — adding a station to a previously stationless log must
create the price-history row and bump the station's times_used."""
log = FuelLog(
vehicle_id=sample_vehicle.id,
user_id=test_user.id,
date=date(2026, 4, 15),
odometer=20000,
volume=40,
price_per_unit=1.50,
total_cost=60.0,
is_full_tank=True,
)
db.session.add(log)
db.session.commit()
log_id = log.id
starting_uses = sample_station.times_used or 0

auth_client.post(f'/fuel/{log_id}/edit', data={
'date': '2026-04-15',
'odometer': '20000',
'volume': '40',
'price_per_unit': '1.50',
'total_cost': '60.0',
'station_id': str(sample_station.id),
'is_full_tank': 'on',
}, follow_redirects=True)

db.session.refresh(sample_station)
assert sample_station.times_used == starting_uses + 1

history = FuelPriceHistory.query.filter_by(
station_id=sample_station.id,
price_per_unit=1.50,
).first()
assert history is not None
assert history.date == date(2026, 4, 15)


class TestFuelQuick:
def test_quick_requires_auth(self, client):
Expand Down
Loading