From d2f0077e86160e5544d0b434832dd14c1f4a0b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 14 Jul 2026 13:33:28 +0200 Subject: [PATCH] =?UTF-8?q?edycja=20tankowa=C5=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 135 +++++++++++++++++++++++++- app/static/js/fuel_entries.js | 1 + app/static/js/init.js | 2 +- app/templates/_admin_nav.html | 1 + app/templates/admin_fuel_entries.html | 28 ++++++ app/templates/base.html | 1 + 6 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 app/static/js/fuel_entries.js create mode 100644 app/templates/admin_fuel_entries.html diff --git a/app/main.py b/app/main.py index b9ae202..d669cce 100644 --- a/app/main.py +++ b/app/main.py @@ -4,7 +4,7 @@ from decimal import Decimal from functools import wraps from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, abort from flask_login import login_required, current_user -from sqlalchemy import extract, or_ +from sqlalchemy import extract, or_, and_ from sqlalchemy.exc import IntegrityError from .extensions import db from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, OrlenPrice, FuelCardPolicy, FuelStationCompany, FuelStationPoint, FuelCard, FuelCardStationRule @@ -43,6 +43,63 @@ def roles(*allowed): return wrapped return deco + + +def fuel_entry_accessible(entry): + if current_user.role == "admin": + return True + if current_user.role != "boss" or not current_user.company_id: + return False + company_id = entry.vehicle.company_id or (entry.vehicle.owner.company_id if entry.vehicle and entry.vehicle.owner else None) + return company_id == current_user.company_id + +def recalculate_vehicle_odometer(vehicle): + latest = (db.session.query(db.func.max(FuelEntry.odometer)) + .filter(FuelEntry.vehicle_id == vehicle.id) + .scalar()) + vehicle.current_odometer = int(latest or 0) + +def parse_fuel_entry_form(entry): + vehicle_id = request.form.get("vehicle_id", type=int) + vehicle = db.session.get(Vehicle, vehicle_id) + if not vehicle: + raise ValueError("Nie znaleziono pojazdu") + if current_user.role == "boss" and (vehicle.company_id or vehicle.owner.company_id) != current_user.company_id: + abort(403) + fuel_type = (request.form.get("fuel_type") or "").upper() + if fuel_type not in FUEL_TYPES: + raise ValueError("Nieprawidłowy rodzaj paliwa") + fueled_at = datetime.fromisoformat(request.form.get("fueled_at", "")) + liters = Decimal(request.form.get("liters", "")) + price = Decimal(request.form.get("price_per_liter", "")) + odometer = int(request.form.get("odometer", "")) + if liters <= 0: + raise ValueError("Liczba litrów musi być większa od zera") + if price < 0 or odometer < 0: + raise ValueError("Cena i stan licznika nie mogą być ujemne") + station_company_id = request.form.get("station_company_id", type=int) + station_company = db.session.get(FuelStationCompany, station_company_id) if station_company_id else None + fuel_card_id = request.form.get("fuel_card_id", type=int) + fuel_card = db.session.get(FuelCard, fuel_card_id) if fuel_card_id else None + company_id = vehicle.company_id or vehicle.owner.company_id + if fuel_card and fuel_card.company_id and fuel_card.company_id != company_id: + raise ValueError("Wybrana karta nie należy do firmy pojazdu") + entry.vehicle = vehicle + entry.fueled_at = fueled_at + entry.liters = liters + entry.price_per_liter = price + entry.fuel_type = fuel_type + entry.odometer = odometer + entry.station_company = station_company + entry.station = station_company.company_name if station_company else (request.form.get("station") or "").strip() or None + entry.invoice_number = (request.form.get("invoice_number") or "").strip() or None + entry.used_fuel_card = "used_fuel_card" in request.form + entry.fuel_card = fuel_card if entry.used_fuel_card else None + wholesale_price = (request.form.get("wholesale_price") or "").strip() + entry.wholesale_price = Decimal(wholesale_price) if wholesale_price else None + entry.wholesale_source = (request.form.get("wholesale_source") or "").strip() or None + return vehicle + def accessible_vehicles(): if current_user.role == "admin": return Vehicle.query.order_by(Vehicle.name).all() @@ -374,6 +431,82 @@ def orlen_sync(): except Exception as exc: db.session.rollback(); return jsonify({"ok": False, "error": str(exc)}), 502 + + +@main.get("/admin/fuel-entries") +@login_required +@roles("boss", "admin") +def admin_fuel_entries(): + q = request.args.get("q", "").strip() + month = request.args.get("month", "").strip() + company_id = request.args.get("company_id", type=int) + page = request.args.get("page", 1, type=int) + query = FuelEntry.query.join(Vehicle).join(User, Vehicle.owner_id == User.id) + if current_user.role == "boss": + query = query.filter(or_(Vehicle.company_id == current_user.company_id, and_(Vehicle.company_id.is_(None), User.company_id == current_user.company_id))) + company_id = current_user.company_id + elif company_id: + query = query.filter(or_(Vehicle.company_id == company_id, and_(Vehicle.company_id.is_(None), User.company_id == company_id))) + if month: + try: + year, mon = map(int, month.split("-")) + query = query.filter(extract("year", FuelEntry.fueled_at) == year, extract("month", FuelEntry.fueled_at) == mon) + except ValueError: + month = "" + if q: + like = f"%{q}%" + query = query.outerjoin(FuelStationCompany, FuelEntry.station_company_id == FuelStationCompany.id).filter(or_( + Vehicle.name.ilike(like), Vehicle.registration.ilike(like), FuelEntry.station.ilike(like), + FuelEntry.invoice_number.ilike(like), User.name.ilike(like), User.email.ilike(like), + FuelStationCompany.company_name.ilike(like), FuelStationCompany.brand_name.ilike(like) + )) + entries_page = query.order_by(FuelEntry.fueled_at.desc(), FuelEntry.id.desc()).paginate(page=page, per_page=25, error_out=False) + companies = CompanySettings.query.order_by(CompanySettings.name).all() if current_user.role == "admin" else [current_user.company] + vehicles_q = Vehicle.query.join(User, Vehicle.owner_id == User.id) + cards_q = FuelCard.query.filter_by(active=True) + if current_user.role == "boss": + vehicles_q = vehicles_q.filter(or_(Vehicle.company_id == current_user.company_id, and_(Vehicle.company_id.is_(None), User.company_id == current_user.company_id))) + cards_q = cards_q.filter_by(company_id=current_user.company_id) + elif company_id: + vehicles_q = vehicles_q.filter(or_(Vehicle.company_id == company_id, and_(Vehicle.company_id.is_(None), User.company_id == company_id))) + cards_q = cards_q.filter_by(company_id=company_id) + return render_template("admin_fuel_entries.html", entries_page=entries_page, q=q, month=month, company_id=company_id, companies=companies, vehicles=vehicles_q.order_by(Vehicle.name).all(), stations=FuelStationCompany.query.filter_by(active=True).order_by(FuelStationCompany.brand_name, FuelStationCompany.company_name).all(), fuel_cards=cards_q.order_by(FuelCard.name).all()) + +@main.put("/api/fuel-entries/") +@login_required +@roles("boss", "admin") +def update_fuel_entry(entry_id): + entry = FuelEntry.query.get_or_404(entry_id) + if not fuel_entry_accessible(entry): + abort(403) + old_vehicle = entry.vehicle + try: + new_vehicle = parse_fuel_entry_form(entry) + db.session.flush() + recalculate_vehicle_odometer(old_vehicle) + if new_vehicle.id != old_vehicle.id: + recalculate_vehicle_odometer(new_vehicle) + db.session.commit() + except (ValueError, ArithmeticError) as exc: + db.session.rollback() + return jsonify({"ok": False, "error": str(exc)}), 400 + return jsonify({"ok": True, "message": "Zaktualizowano tankowanie"}) + +@main.delete("/api/fuel-entries/") +@login_required +@roles("boss", "admin") +def delete_fuel_entry(entry_id): + entry = FuelEntry.query.get_or_404(entry_id) + if not fuel_entry_accessible(entry): + abort(403) + vehicle = entry.vehicle + db.session.delete(entry) + db.session.flush() + recalculate_vehicle_odometer(vehicle) + db.session.commit() + return jsonify({"ok": True, "message": "Usunięto tankowanie"}) + + @main.route("/admin", methods=["GET","POST"]) @login_required @roles("boss", "admin") diff --git a/app/static/js/fuel_entries.js b/app/static/js/fuel_entries.js new file mode 100644 index 0000000..d5c8a4f --- /dev/null +++ b/app/static/js/fuel_entries.js @@ -0,0 +1 @@ +(()=>{const F=FuelTrack;F.bindFuelEntryModal=()=>{const form=F.qs('#fuel-entry-edit-form'),del=F.qs('#fuel-entry-delete-btn');if(!form)return;F.qsa('.fuel-entry-edit-btn').forEach(btn=>{if(btn.dataset.bound)return;btn.dataset.bound='1';btn.addEventListener('click',()=>{const d=JSON.parse(btn.dataset.entry);form.action=`/api/fuel-entries/${d.id}`;form.dataset.entryId=d.id;['vehicle_id','fueled_at','fuel_type','liters','price_per_liter','odometer','station_company_id','station','invoice_number','fuel_card_id','wholesale_price','wholesale_source'].forEach(k=>{if(form.elements[k])form.elements[k].value=d[k]??''});form.elements.used_fuel_card.checked=Boolean(d.used_fuel_card)})});if(del&&!del.dataset.bound){del.dataset.bound='1';del.addEventListener('click',async()=>{const id=form.dataset.entryId;if(!id||!confirm('Usunąć to tankowanie? Tej operacji nie można cofnąć.'))return;const old=del.innerHTML;del.disabled=true;del.innerHTML='Usuwanie…';try{const r=await fetch(`/api/fuel-entries/${id}`,{method:'DELETE',headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),d=await r.json();if(!r.ok||!d.ok)throw new Error(d.error||d.message||'Nie udało się usunąć tankowania.');F.notify(d.message);bootstrap.Modal.getInstance(form.closest('.modal'))?.hide();await F.replaceMain(location.href)}catch(e){F.notify(e.message,'danger')}finally{del.disabled=false;del.innerHTML=old}})}}})(); diff --git a/app/static/js/init.js b/app/static/js/init.js index 0ae8ad5..614af4c 100644 --- a/app/static/js/init.js +++ b/app/static/js/init.js @@ -1 +1 @@ -FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>FuelTrack.initPage()); +FuelTrack.initPage=()=>{FuelTrack.bindAjaxForms();FuelTrack.bindFuelPreview();FuelTrack.bindOrlenSync();FuelTrack.bindUserModal();FuelTrack.bindFuelEntryModal();FuelTrack.bindAuth();FuelTrack.bindStationLiveSearch();FuelTrack.bindStationFavoriteToggles();FuelTrack.bindStationInlineCompany();FuelTrack.bindLpgRegionPicker();FuelTrack.bindFavoriteStations();FuelTrack.bindStationProposalSelect();FuelTrack.bindStationPoints()};window.addEventListener('popstate',()=>FuelTrack.replaceMain(location.href));document.addEventListener('DOMContentLoaded',()=>FuelTrack.initPage()); diff --git a/app/templates/_admin_nav.html b/app/templates/_admin_nav.html index 12fded9..f7b5cad 100644 --- a/app/templates/_admin_nav.html +++ b/app/templates/_admin_nav.html @@ -2,4 +2,5 @@ {% if current_user.role=='admin' %}{% endif %} + diff --git a/app/templates/admin_fuel_entries.html b/app/templates/admin_fuel_entries.html new file mode 100644 index 0000000..b19f53e --- /dev/null +++ b/app/templates/admin_fuel_entries.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %}{% block content %} +

Administracja

Korekta i usuwanie zapisanych tankowań.

+{% include '_admin_nav.html' %} +
+
+ {% if current_user.role=='admin' %}
{% endif %} +
+
+
+
+
+{% for e in entries_page.items %} +{% else %}{% endfor %}
DataPojazdUżytkownikStacjaLitryCenaLicznikAkcja
{{e.fueled_at.strftime('%d.%m.%Y %H:%M')}}{{e.vehicle.name}}
{{e.vehicle.registration}}
{{e.user.name}}{{e.station or '—'}}{{e.liters}}{{e.price_per_liter}} zł/l{{e.odometer}} km
Brak tankowań spełniających kryteria.
+{% if entries_page.pages > 1 %}{% endif %} +
+ +{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 6b6b54c..3544932 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -55,6 +55,7 @@ + {% block scripts %}{% endblock %}