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
49 changes: 37 additions & 12 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,20 +269,25 @@ def get_total_distance(self, distance_unit=None):
Otherwise, calculates from fuel log entries.

Args:
distance_unit: If provided ('km' or 'mi'), converts to this unit.
distance_unit: If provided ('km' or 'mi'), converts the result
to this unit. Otherwise returns the raw value in the
vehicle's effective odometer unit.
"""
# If Tessie is enabled, use the odometer reading directly
# If Tessie is enabled, use the odometer reading directly (always stored in km)
if self.uses_tessie_odometer() and self.tessie_last_odometer:
odometer = self.tessie_last_odometer # Stored in km
odometer = self.tessie_last_odometer
if distance_unit == 'mi':
return odometer * 0.621371
return odometer

# Otherwise calculate from fuel logs
# Otherwise calculate from fuel logs, stored in the vehicle's odometer unit
logs = self.fuel_logs.order_by(FuelLog.odometer).all()
if len(logs) < 2:
return 0
return logs[-1].odometer - logs[0].odometer
raw_distance = logs[-1].odometer - logs[0].odometer
if distance_unit:
return _distance_in(raw_distance, self.get_effective_odometer_unit(), distance_unit)
return raw_distance

def get_average_consumption(self, consumption_unit=None, volume_unit='L'):
"""Calculate average fuel consumption between the first and last
Expand Down Expand Up @@ -310,14 +315,18 @@ def get_average_consumption(self, consumption_unit=None, volume_unit='L'):
total_distance = last_odo - first_odo

if total_distance > 0 and total_fuel > 0:
odometer_unit = self.get_effective_odometer_unit()
if consumption_unit == 'mpg':
miles = _distance_in(total_distance, odometer_unit, 'mi')
gallons = _to_uk_gallons(total_fuel, volume_unit)
return total_distance / gallons if gallons > 0 else None
return miles / gallons if gallons > 0 else None
if consumption_unit == 'mpg_us':
miles = _distance_in(total_distance, odometer_unit, 'mi')
gallons = _to_us_gallons(total_fuel, volume_unit)
return total_distance / gallons if gallons > 0 else None
return miles / gallons if gallons > 0 else None
km = _distance_in(total_distance, odometer_unit, 'km')
litres = _to_litres(total_fuel, volume_unit)
return (litres / total_distance) * 100 # L/100km
return (litres / km) * 100 # L/100km
return None

def uses_tessie_odometer(self):
Expand Down Expand Up @@ -348,7 +357,7 @@ def get_last_odometer(self, distance_unit=None):
last_fuel = self.fuel_logs.order_by(FuelLog.odometer.desc()).first()
fuel_odo = last_fuel.odometer if last_fuel else 0

last_trip = self.trips.order_by(Trip.end_odometer.desc()).first()
last_trip = self.trips.filter(Trip.end_odometer.isnot(None)).order_by(Trip.end_odometer.desc()).first()
trip_odo = last_trip.end_odometer if last_trip else 0

last_charge = self.charging_sessions.filter(ChargingSession.odometer.isnot(None)).order_by(
Expand Down Expand Up @@ -515,6 +524,16 @@ def _to_us_gallons(volume, volume_unit):
return volume / 3.78541 # litres to US gallons


def _distance_in(distance, from_unit, to_unit):
if from_unit == to_unit:
return distance
if from_unit == 'km' and to_unit == 'mi':
return distance * 0.621371
if from_unit == 'mi' and to_unit == 'km':
return distance * 1.609344
return distance


class FuelLog(db.Model):
__tablename__ = 'fuel_logs'

Expand Down Expand Up @@ -585,14 +604,18 @@ def get_consumption(self, consumption_unit=None, volume_unit='L'):
volume_native = self.volume

if distance > 0 and volume_native > 0:
odometer_unit = self.vehicle.get_effective_odometer_unit()
if consumption_unit == 'mpg':
miles = _distance_in(distance, odometer_unit, 'mi')
gallons = _to_uk_gallons(volume_native, volume_unit)
return distance / gallons if gallons > 0 else None
return miles / gallons if gallons > 0 else None
if consumption_unit == 'mpg_us':
miles = _distance_in(distance, odometer_unit, 'mi')
gallons = _to_us_gallons(volume_native, volume_unit)
return distance / gallons if gallons > 0 else None
return miles / gallons if gallons > 0 else None
km = _distance_in(distance, odometer_unit, 'km')
litres = _to_litres(volume_native, volume_unit)
return (litres / distance) * 100 # L/100km
return (litres / km) * 100 # L/100km
return None

def to_dict(self, consumption_unit=None, volume_unit='L'):
Expand Down Expand Up @@ -1218,6 +1241,8 @@ class Trip(db.Model):
@property
def distance(self):
"""Calculate trip distance"""
if self.end_odometer is None or self.start_odometer is None:
return 0
return self.end_odometer - self.start_odometer

def to_dict(self):
Expand Down
23 changes: 15 additions & 8 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,14 +1113,16 @@ def export_csv():
writer.writerow([
'id', 'name', 'vehicle_type', 'make', 'model', 'year',
'registration', 'vin', 'fuel_type', 'tank_capacity',
'is_active', 'notes', 'created_at'
'odometer_unit', 'is_active', 'notes', 'created_at'
])
for vehicle in current_user.get_all_vehicles():
writer.writerow([
vehicle.id, vehicle.name, vehicle.vehicle_type,
vehicle.make, vehicle.model, vehicle.year,
vehicle.registration, vehicle.vin, vehicle.fuel_type,
vehicle.tank_capacity, vehicle.is_active, vehicle.notes,
vehicle.tank_capacity,
vehicle.get_effective_odometer_unit(),
vehicle.is_active, vehicle.notes,
vehicle.created_at.isoformat() if vehicle.created_at else ''
])
zip_file.writestr('vehicles.csv', vehicles_csv.getvalue())
Expand All @@ -1140,15 +1142,17 @@ def export_csv():
fuel_csv = io.StringIO()
writer = csv.writer(fuel_csv)
writer.writerow([
'id', 'vehicle_id', 'vehicle_name', 'date', 'odometer',
'id', 'vehicle_id', 'vehicle_name', 'date', 'odometer', 'odometer_unit',
'volume', 'price_per_unit', 'total_cost', 'is_full_tank',
'is_missed', 'station', 'notes', 'created_at'
])
for vehicle in current_user.get_all_vehicles():
odometer_unit = vehicle.get_effective_odometer_unit()
for log in vehicle.fuel_logs.order_by(FuelLog.date.desc()).all():
writer.writerow([
log.id, vehicle.id, vehicle.name, log.date.isoformat(),
log.odometer, log.volume, log.price_per_unit, log.total_cost,
log.odometer, odometer_unit,
log.volume, log.price_per_unit, log.total_cost,
log.is_full_tank, log.is_missed, log.station, log.notes,
log.created_at.isoformat() if log.created_at else ''
])
Expand All @@ -1159,14 +1163,16 @@ def export_csv():
writer = csv.writer(expenses_csv)
writer.writerow([
'id', 'vehicle_id', 'vehicle_name', 'date', 'category',
'description', 'cost', 'odometer', 'vendor', 'notes', 'created_at'
'description', 'cost', 'odometer', 'odometer_unit', 'vendor', 'notes', 'created_at'
])
for vehicle in current_user.get_all_vehicles():
odometer_unit = vehicle.get_effective_odometer_unit()
for expense in vehicle.expenses.order_by(Expense.date.desc()).all():
writer.writerow([
expense.id, vehicle.id, vehicle.name, expense.date.isoformat(),
expense.category, expense.description, expense.cost,
expense.odometer, expense.vendor, expense.notes,
expense.odometer, odometer_unit if expense.odometer is not None else '',
expense.vendor, expense.notes,
expense.created_at.isoformat() if expense.created_at else ''
])
zip_file.writestr('expenses.csv', expenses_csv.getvalue())
Expand Down Expand Up @@ -1266,15 +1272,16 @@ def export_csv():
writer = csv.writer(trips_csv)
writer.writerow([
'id', 'vehicle_id', 'vehicle_name', 'date', 'start_odometer', 'end_odometer',
'distance', 'purpose', 'description', 'start_location', 'end_location',
'distance', 'distance_unit', 'purpose', 'description', 'start_location', 'end_location',
'notes', 'created_at'
])
for vehicle in current_user.get_all_vehicles():
odometer_unit = vehicle.get_effective_odometer_unit()
for trip in vehicle.trips.order_by(Trip.date.desc()).all():
writer.writerow([
trip.id, vehicle.id, vehicle.name,
trip.date.isoformat() if trip.date else '',
trip.start_odometer, trip.end_odometer, trip.distance,
trip.start_odometer, trip.end_odometer, trip.distance, odometer_unit,
trip.purpose, trip.description,
trip.start_location, trip.end_location,
trip.notes,
Expand Down
21 changes: 21 additions & 0 deletions app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,27 @@
document.addEventListener('htmx:configRequest', function(event) {
event.detail.headers['X-CSRFToken'] = csrfToken;
});

// Generic POST helper for buttons that would otherwise need a nested form.
// Nested <form> elements are invalid HTML — the browser silently closes the outer form,
// orphaning later submit buttons (issue #182).
document.addEventListener('click', function(event) {
const btn = event.target.closest('[data-post-url]');
if (!btn) return;
event.preventDefault();
const confirmMessage = btn.getAttribute('data-confirm');
if (confirmMessage && !window.confirm(confirmMessage)) return;
const form = document.createElement('form');
form.method = 'POST';
form.action = btn.getAttribute('data-post-url');
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = 'csrf_token';
tokenInput.value = csrfToken;
form.appendChild(tokenInput);
document.body.appendChild(form);
form.submit();
});
})();

// Flatpickr date picker initialization
Expand Down
9 changes: 5 additions & 4 deletions app/templates/expenses/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,11 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">{% if expense
{% for attachment in expense.attachments.all() %}
<div class="mt-2 flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<a href="{{ url_for('api.uploaded_file', filename=attachment.filename) }}" target="_blank" class="text-primary-600 hover:text-primary-500">{{ attachment.original_filename }}</a>
<form method="POST" action="{{ url_for('expenses.delete_attachment', expense_id=expense.id, attachment_id=attachment.id) }}" onsubmit="return confirm('Delete this attachment?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-600 hover:text-red-800 text-xs">Delete</button>
</form>
<button type="button" class="text-red-600 hover:text-red-800 text-xs"
data-post-url="{{ url_for('expenses.delete_attachment', expense_id=expense.id, attachment_id=attachment.id) }}"
data-confirm="{{ _('Delete this attachment?') }}">
{{ _('Delete') }}
</button>
</div>
{% endfor %}
{% endif %}
Expand Down
9 changes: 5 additions & 4 deletions app/templates/fuel/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,11 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white mt-2">{% if log %}Ed
{% for attachment in log.attachments.all() %}
<div class="mt-2 flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
<a href="{{ url_for('api.uploaded_file', filename=attachment.filename) }}" target="_blank" class="text-primary-600 hover:text-primary-500">{{ attachment.original_filename }}</a>
<form method="POST" action="{{ url_for('fuel.delete_attachment', log_id=log.id, attachment_id=attachment.id) }}" onsubmit="return confirm('Delete this attachment?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-600 hover:text-red-800 text-xs">Delete</button>
</form>
<button type="button" class="text-red-600 hover:text-red-800 text-xs"
data-post-url="{{ url_for('fuel.delete_attachment', log_id=log.id, attachment_id=attachment.id) }}"
data-confirm="{{ _('Delete this attachment?') }}">
{{ _('Delete') }}
</button>
</div>
{% endfor %}
{% endif %}
Expand Down
29 changes: 22 additions & 7 deletions app/templates/fuel/quick.html
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,24 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _('Quick Fuel En
<label for="is_full_tank" class="ml-3 text-sm text-gray-700 dark:text-gray-300">{{ _('Full tank') }}</label>
</div>

<!-- Station (with datalist from saved stations) -->
<!-- Station (select for saved stations, fallback to free text) -->
<div>
<label for="station" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Station') }}</label>
<input type="text" name="station" id="station" list="station-list"
placeholder="{{ _('Optional') }}"
class="mt-1 block w-full rounded-lg border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white py-3 focus:border-primary-500 focus:ring-primary-500">
<datalist id="station-list">
{% if stations %}
<select name="station_id" id="station_id" onchange="updateQuickStationName(this)"
class="mt-1 block w-full rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-white py-3 focus:border-primary-500 focus:ring-primary-500">
<option value="">{{ _('Select station or enter manually...') }}</option>
{% for station in stations %}
<option value="{{ station.name }}">{{ station.brand }} - {{ station.address }}</option>
<option value="{{ station.id }}" data-name="{{ station.name }}{% if station.brand %} ({{ station.brand }}){% endif %}">
{{ station.name }}{% if station.brand %} ({{ station.brand }}){% endif %}
{% if station.is_favorite %}*{% endif %}
</option>
{% endfor %}
</datalist>
</select>
{% endif %}
<input type="text" name="station" id="station"
placeholder="{{ _('Optional') }}"
class="mt-1 block w-full rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-white py-3 focus:border-primary-500 focus:ring-primary-500">
</div>

<!-- Submit Buttons -->
Expand Down Expand Up @@ -166,6 +173,14 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _('Quick Fuel En
}
}

function updateQuickStationName(selectEl) {
const stationInput = document.getElementById('station');
if (!stationInput) return;
const selectedOption = selectEl.options[selectEl.selectedIndex];
const name = selectedOption ? selectedOption.getAttribute('data-name') : '';
if (name) stationInput.value = name;
}

function calcQuickFuel(changed) {
const volume = parseFloat(document.getElementById('volume').value);
const price = parseFloat(document.getElementById('price_per_unit').value);
Expand Down
2 changes: 1 addition & 1 deletion app/templates/trips/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _('Trips') }}</h
<div class="ml-4 flex items-center gap-4">
<div class="text-right">
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ "%.1f"|format(trip.distance) }} {{ current_user.distance_unit }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ "%.0f"|format(trip.start_odometer) }} &rarr; {{ "%.0f"|format(trip.end_odometer) }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ "%.0f"|format(trip.start_odometer) }} &rarr; {{ "%.0f"|format(trip.end_odometer or 0) }}</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ url_for('trips.edit', trip_id=trip.id) }}"
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.2'
APP_VERSION = '0.22.3'
RELEASE_CHANNEL = os.environ.get('RELEASE_CHANNEL', 'stable')
GIT_SHA = os.environ.get('GIT_SHA', '')[:7] # Short SHA
GITHUB_REPO = 'dannymcc/may'
Expand Down
10 changes: 5 additions & 5 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
flask>=3.0.0
flask>=3.1.3
flask-sqlalchemy>=3.1.1
flask-migrate>=4.1.0
flask-login>=0.6.3
flask-wtf>=1.2.1
flask-wtf>=1.3.0
flask-babel>=4.0.0
werkzeug>=3.1.8
python-dotenv>=1.0.0
python-dotenv>=1.2.2
pillow>=10.4.0
gunicorn>=25.3.0
weasyprint>=62.0
requests>=2.33.1
python-dateutil>=2.9.0.post0
pytest>=8.0.0
pytest>=9.0.3
pytest-cov>=4.1.0
coverage>=7.0.0
coverage>=7.13.5
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ def sample_vehicle(app, test_user):
model='Corolla',
year=2023,
fuel_type='petrol',
odometer_unit='km',
)
_db_ext.session.add(vehicle)
_db_ext.session.commit()
Expand Down
13 changes: 13 additions & 0 deletions tests/test_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ def test_export_csv_with_data(self, auth_client, sample_vehicle, sample_fuel_log
vehicles_csv = zf.read('vehicles.csv').decode('utf-8')
assert 'Test Car' in vehicles_csv

def test_export_csv_includes_odometer_unit(self, auth_client, sample_vehicle, sample_fuel_log, sample_expense):
"""#173 — odometer values must be self-describing about units."""
resp = auth_client.get('/api/export/csv')
assert resp.status_code == 200
buf = io.BytesIO(resp.data)
with zipfile.ZipFile(buf) as zf:
vehicles_csv = zf.read('vehicles.csv').decode('utf-8')
fuel_csv = zf.read('fuel_logs.csv').decode('utf-8')
expenses_csv = zf.read('expenses.csv').decode('utf-8')
assert 'odometer_unit' in vehicles_csv.splitlines()[0]
assert 'odometer_unit' in fuel_csv.splitlines()[0]
assert 'odometer_unit' in expenses_csv.splitlines()[0]


class TestExportJson:
def test_export_json_requires_auth(self, client):
Expand Down
Loading