446 lines
21 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
Uber Delivery Tracking Page standalone HTML, no API key required.
Uses OpenStreetMap (free, no key) + Leaflet.js for live route map.
"""
from odoo import http
from odoo.http import request, Response
import urllib.parse
import logging
_logger = logging.getLogger(__name__)
class UberTrackingController(http.Controller):
@http.route([
'/uber/track/sale/<int:order_id>',
'/uber/track/pos/<int:order_id>',
], type='http', auth='user', website=False)
def track_delivery(self, order_id, **kwargs):
path = request.httprequest.path
# ── Load order ─────────────────────────────────────────────
if '/sale/' in path:
order = request.env['sale.order'].sudo().browse(order_id)
model_type = 'sale'
partner = order.partner_shipping_id or order.partner_id
order_ref = order.name or f'SO{order_id}'
customer_name = partner.name if partner else 'Customer'
customer_phone = (partner.phone or partner.mobile or '') if partner else ''
dropoff_street = (partner.street or '') if partner else ''
dropoff_city = (partner.city or '') if partner else ''
dropoff_state = (partner.state_id.name if partner and partner.state_id else '')
dropoff_zip = (partner.zip or '') if partner else ''
dropoff_country = (partner.country_id.name if partner and partner.country_id else '')
uber_status = order.uber_status or ''
uber_tracking_url = order.uber_tracking_url or ''
uber_delivery_id = order.uber_delivery_id or ''
uber_eta = str(order.uber_eta)[:16] if order.uber_eta else ''
else:
order = request.env['pos.order'].sudo().browse(order_id)
model_type = 'pos'
partner = order.partner_id
order_ref = order.pos_reference or order.name or f'PO{order_id}'
customer_name = partner.name if partner else 'Customer'
customer_phone = (partner.phone or partner.mobile or '') if partner else ''
dropoff_street = getattr(order, 'delivery_street', '') or ((partner.street or '') if partner else '')
dropoff_city = getattr(order, 'delivery_city', '') or ((partner.city or '') if partner else '')
dropoff_state = (partner.state_id.name if partner and partner.state_id else '') if partner else ''
dropoff_zip = getattr(order, 'delivery_zip', '') or ((partner.zip or '') if partner else '')
dropoff_country = (partner.country_id.name if partner and partner.country_id else '') if partner else ''
uber_status = order.uber_status or ''
uber_tracking_url = order.uber_tracking_url or ''
uber_delivery_id = order.uber_delivery_id or ''
uber_eta = str(order.uber_eta)[:16] if getattr(order, 'uber_eta', False) else ''
# ── Restaurant pickup ───────────────────────────────────────
company = request.env.company
pickup_name = company.name or 'Restaurant'
pickup_street = company.street or ''
pickup_city = company.city or ''
pickup_state = company.state_id.name if company.state_id else ''
pickup_zip = company.zip or ''
pickup_country = company.country_id.name if company.country_id else ''
pickup_phone = company.phone or ''
def _addr(*parts):
return ', '.join(p for p in parts if p)
pickup_addr = _addr(pickup_street, pickup_city, pickup_state, pickup_zip, pickup_country)
dropoff_addr = _addr(dropoff_street, dropoff_city, dropoff_state, dropoff_zip, dropoff_country)
# Google Maps directions URL (opens in browser, no key needed)
gmaps_url = (
'https://www.google.com/maps/dir/?api=1'
f'&origin={urllib.parse.quote(pickup_addr)}'
f'&destination={urllib.parse.quote(dropoff_addr)}'
'&travelmode=driving'
)
# OpenStreetMap static route embed (no API key)
osm_embed = (
'https://www.openstreetmap.org/export/embed.html'
f'?bbox={self._bbox(pickup_addr, dropoff_addr)}'
'&layer=mapnik'
'&marker=yes'
)
# Status info
status_map = {
'pending': ('#f59e0b', '⏳ Waiting for Driver', '⚠️'),
'pickup': ('#3b82f6', '🚗 Driver at Restaurant', ''),
'delivering': ('#06b6d4', '🛵 On the Way to You', '🔵'),
'delivered': ('#10b981', '✅ Order Delivered!', ''),
'cancelled': ('#ef4444', '❌ Delivery Cancelled', ''),
}
st_color, st_label, st_icon = status_map.get(uber_status, ('#64748b', '❓ Unknown', ''))
back_url = f'/odoo/{model_type}-orders/{order_id}' if model_type == 'sale' else f'/odoo/point-of-sale/orders/{order_id}'
html = self._render_page(
order_ref=order_ref, model_type=model_type, order_id=order_id,
pickup_name=pickup_name, pickup_street=pickup_street,
pickup_city=pickup_city, pickup_state=pickup_state,
pickup_zip=pickup_zip, pickup_country=pickup_country,
pickup_phone=pickup_phone, pickup_addr=pickup_addr,
customer_name=customer_name, customer_phone=customer_phone,
dropoff_street=dropoff_street, dropoff_city=dropoff_city,
dropoff_state=dropoff_state, dropoff_zip=dropoff_zip,
dropoff_country=dropoff_country, dropoff_addr=dropoff_addr,
uber_status=uber_status, st_color=st_color, st_label=st_label,
uber_delivery_id=uber_delivery_id, uber_eta=uber_eta,
uber_tracking_url=uber_tracking_url, gmaps_url=gmaps_url,
)
return Response(html, content_type='text/html; charset=utf-8')
def _bbox(self, addr1, addr2):
"""Placeholder bbox — Leaflet geocodes addresses on client side."""
return '-180,-90,180,90'
def _render_page(self, **v):
# Safe helper
def esc(s):
return str(s or '').replace('&', '&amp;').replace('<', '&lt;').replace('"', '&quot;')
show_uber_btn = v['uber_tracking_url'] and 'sandbox' not in v['uber_tracking_url']
# Pre-build strings that contain backslashes (not allowed inside f-string expressions)
pickup_icon_html = '<div style="background:#06b6d4;width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;box-shadow:0 2px 8px rgba(0,0,0,.3);border:3px solid #fff;">&#127978;</div>'
dropoff_icon_html = '<div style="background:#f59e0b;width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:18px;box-shadow:0 2px 8px rgba(0,0,0,.3);border:3px solid #fff;">&#127968;</div>'
eta_chip = ('<div class="info-chip"><div class="lbl">&#9201; Estimated Delivery</div><div class="val">' + esc(v['uber_eta']) + '</div></div>') if v['uber_eta'] else ''
id_chip = ('<div class="info-chip"><div class="lbl">&#128278; Uber Delivery ID</div><div class="val">' + esc(v['uber_delivery_id']) + '</div></div>') if v['uber_delivery_id'] else ''
pickup_phone_html = ('<div class="card-phone">&#128222; ' + esc(v['pickup_phone']) + '</div>') if v['pickup_phone'] else ''
dropoff_phone_html = ('<div class="card-phone">&#128222; ' + esc(v['customer_phone']) + '</div>') if v['customer_phone'] else ''
pickup_br = '<br/>' if v['pickup_street'] else ''
dropoff_br = '<br/>' if v['dropoff_street'] else ''
pickup_state_part = (', ' + esc(v['pickup_state'])) if v['pickup_state'] else ''
pickup_zip_part = (' &ndash; ' + esc(v['pickup_zip'])) if v['pickup_zip'] else ''
pickup_country_part = ('<br/>' + esc(v['pickup_country'])) if v['pickup_country'] else ''
dropoff_state_part = (', ' + esc(v['dropoff_state'])) if v['dropoff_state'] else ''
dropoff_zip_part = (' &ndash; ' + esc(v['dropoff_zip'])) if v['dropoff_zip'] else ''
dropoff_country_part = ('<br/>' + esc(v['dropoff_country'])) if v['dropoff_country'] else ''
uber_fab = ('<a href="' + esc(v['uber_tracking_url']) + '" target="_blank" class="fab fab-uber">&#9889; Uber Live</a>') if show_uber_btn else ''
uber_btn = ('<a href="' + esc(v['uber_tracking_url']) + '" target="_blank" class="btn btn-dark">&#9889; Live Track on Uber</a>') if show_uber_btn else ''
st_color = esc(v['st_color'])
return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Uber Delivery Tracking {esc(v["order_ref"])}</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f4f8;min-height:100vh}}
/* ── Header ───────────────────────── */
.hdr{{background:linear-gradient(135deg,#0f172a 0%,#1e3a5f 100%);
color:#fff;padding:14px 20px;display:flex;align-items:center;
gap:14px;box-shadow:0 4px 16px rgba(0,0,0,.3)}}
.hdr-logo{{background:#fff;color:#000;font-weight:900;font-size:22px;
letter-spacing:-1px;border-radius:8px;padding:4px 12px}}
.hdr-title{{font-size:13px;opacity:.7;text-transform:uppercase;letter-spacing:.8px}}
.hdr-ref{{margin-left:auto;background:rgba(255,255,255,.15);
border-radius:20px;padding:5px 14px;font-size:13px;font-weight:700}}
/* ── Status banner ─────────────────── */
.status-bar{{margin:14px 16px 0;border-radius:14px;padding:14px 18px;
display:flex;align-items:center;gap:10px;
border:2px solid {st_color}33;
background:{st_color}18;
color:{st_color};font-weight:700;font-size:15px}}
.dot{{width:10px;height:10px;border-radius:50%;background:{st_color};
animation:pulse 1.5s infinite;flex-shrink:0}}
@keyframes pulse{{0%,100%{{opacity:1;transform:scale(1)}}50%{{opacity:.5;transform:scale(1.4)}}}}
.status-bar .rhs{{margin-left:auto;font-size:11px;font-weight:400;opacity:.7}}
/* ── Info cards ────────────────────── */
.cards{{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:12px 16px}}
@media(max-width:580px){{.cards{{grid-template-columns:1fr}}}}
.card{{background:#fff;border-radius:16px;padding:16px 18px;
box-shadow:0 2px 10px rgba(0,0,0,.07)}}
.card.pickup{{border-top:4px solid #06b6d4}}
.card.dropoff{{border-top:4px solid #f59e0b}}
.card-badge{{display:inline-block;font-size:10px;font-weight:700;
letter-spacing:.8px;text-transform:uppercase;
border-radius:20px;padding:3px 10px;margin-bottom:8px}}
.badge-pickup{{background:#cffafe;color:#0e7490}}
.badge-dropoff{{background:#fef3c7;color:#92400e}}
.card-name{{font-size:15px;font-weight:700;color:#0f172a;margin-bottom:4px}}
.card-addr{{font-size:13px;color:#475569;line-height:1.6}}
.card-phone{{font-size:12px;color:#64748b;margin-top:6px}}
/* ── Map ───────────────────────────── */
.map-wrap{{margin:12px 16px;border-radius:16px;overflow:hidden;
box-shadow:0 4px 20px rgba(0,0,0,.14);position:relative}}
#map{{width:100%;height:400px}}
.map-fab{{position:absolute;top:10px;right:10px;z-index:999;
display:flex;flex-direction:column;gap:6px}}
.fab{{background:#fff;border:none;border-radius:10px;
padding:8px 12px;font-size:12px;font-weight:700;
box-shadow:0 2px 8px rgba(0,0,0,.18);cursor:pointer;
text-decoration:none;color:#0f172a;display:flex;
align-items:center;gap:6px;white-space:nowrap}}
.fab:hover{{box-shadow:0 4px 14px rgba(0,0,0,.22);transform:translateY(-1px)}}
.fab-uber{{background:#000;color:#fff}}
/* ── ETA / ID ──────────────────────── */
.info-row{{display:flex;gap:12px;margin:0 16px 12px;flex-wrap:wrap}}
.info-chip{{background:#fff;border-radius:12px;padding:10px 16px;
flex:1;min-width:140px;box-shadow:0 1px 6px rgba(0,0,0,.07)}}
.info-chip .lbl{{font-size:10px;font-weight:700;letter-spacing:.7px;
text-transform:uppercase;color:#64748b;margin-bottom:3px}}
.info-chip .val{{font-size:14px;font-weight:700;color:#0f172a;word-break:break-all}}
/* ── Action bar ────────────────────── */
.actions{{display:flex;gap:10px;margin:0 16px 20px;flex-wrap:wrap}}
.btn{{flex:1;min-width:130px;padding:13px 18px;border-radius:14px;
border:none;cursor:pointer;font-size:13px;font-weight:700;
display:flex;align-items:center;justify-content:center;gap:8px;
text-decoration:none;transition:transform .15s,box-shadow .15s}}
.btn:hover{{transform:translateY(-2px);box-shadow:0 6px 20px rgba(0,0,0,.15)}}
.btn-gmaps{{background:linear-gradient(135deg,#4285f4,#0d47a1);color:#fff}}
.btn-dark{{background:#111;color:#fff}}
.btn-back{{background:#f1f5f9;color:#0f172a;border:2px solid #e2e8f0}}
.refresh-note{{text-align:center;font-size:11px;color:#94a3b8;margin-bottom:16px}}
</style>
</head>
<body>
<!-- HEADER -->
<div class="hdr">
<div class="hdr-logo">Uber</div>
<div>
<div class="hdr-title">Delivery Tracking</div>
<div style="font-size:16px;font-weight:700;">Live Order Status</div>
</div>
<div class="hdr-ref"># {esc(v["order_ref"])}</div>
</div>
<!-- STATUS -->
<div class="status-bar" id="status_bar">
<div class="dot" id="status_dot"></div>
<span id="status_label">{esc(v["st_label"])}</span>
<span class="rhs">🔄 Auto-refreshes every 30s</span>
</div>
<!-- ETA + DELIVERY ID -->
<div class="info-row">
{eta_chip}
{id_chip}
</div>
<!-- LOCATION CARDS -->
<div class="cards">
<div class="card pickup">
<div class="card-badge badge-pickup">📍 Pickup Location</div>
<div class="card-name">{esc(v["pickup_name"])}</div>
<div class="card-addr">
{esc(v['pickup_street'])}{pickup_br}
{esc(v['pickup_city'])}{pickup_state_part}{pickup_zip_part}
{pickup_country_part}
</div>
{pickup_phone_html}
</div>
<div class="card dropoff">
<div class="card-badge badge-dropoff">&#127919; Delivering To</div>
<div class="card-name">{esc(v['customer_name'])}</div>
<div class="card-addr">
{esc(v['dropoff_street'])}{dropoff_br}
{esc(v['dropoff_city'])}{dropoff_state_part}{dropoff_zip_part}
{dropoff_country_part}
</div>
{dropoff_phone_html}
</div>
</div>
<!-- MAP -->
<div class="map-wrap">
<div id="map"></div>
<div class="map-fab">
<a href="{esc(v["gmaps_url"])}" target="_blank" class="fab">🗺️ Google Maps</a>
{uber_fab}
</div>
</div>
<!-- ACTION BUTTONS -->
<div class="actions">
<a href="{esc(v['gmaps_url'])}" target="_blank" class="btn btn-gmaps">&#128506; Google Maps Directions</a>
{uber_btn}
<a href="javascript:history.back()" class="btn btn-back">&larr; Back to Order</a>
</div>
<div class="refresh-note">Status auto-refreshes every 30 seconds</div>
<!-- Leaflet + routing -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
(function(){{
// ── Geocode via Nominatim (OSM free) ────────────────────────────
var pickupAddr = "{esc(v["pickup_addr"])}";
var dropoffAddr = "{esc(v["dropoff_addr"])}";
var orderId = {v["order_id"]};
var modelType = "{esc(v["model_type"])}";
var map = L.map("map", {{zoomControl:true}}).setView([20, 0], 2);
L.tileLayer("https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png", {{
attribution: "© OpenStreetMap contributors",
maxZoom: 19
}}).addTo(map);
// Custom icons
var pickupIcon = L.divIcon({{
html: '{pickup_icon_html}',
iconSize:[36,36], iconAnchor:[18,18], popupAnchor:[0,-18], className:''
}});
var dropoffIcon = L.divIcon({{
html: '{dropoff_icon_html}',
iconSize:[36,36], iconAnchor:[18,18], popupAnchor:[0,-18], className:''
}});
var pickupMarker = null;
var dropoffMarker = null;
var routeLine = null;
function geocode(addr, callback){{
var url = "https://nominatim.openstreetmap.org/search?format=json&q=" + encodeURIComponent(addr) + "&limit=1";
fetch(url, {{headers:{{"Accept-Language":"en", "User-Agent":"Dine360/1.0"}}}} )
.then(function(r){{return r.json();}})
.then(function(data){{
if(data && data.length > 0){{
callback(parseFloat(data[0].lat), parseFloat(data[0].lon), data[0].display_name);
}} else {{
callback(null, null, addr);
}}
}}).catch(function(){{callback(null, null, addr);}});
}}
function drawRoute(lat1, lon1, lat2, lon2){{
// OSRM free routing
var url = "https://router.project-osrm.org/route/v1/driving/"
+ lon1 + "," + lat1 + ";" + lon2 + "," + lat2
+ "?overview=simplified&geometries=geojson";
fetch(url)
.then(function(r){{return r.json();}})
.then(function(data){{
if(data.routes && data.routes.length > 0){{
if(routeLine) map.removeLayer(routeLine);
routeLine = L.geoJSON(data.routes[0].geometry, {{
style:{{color:"#3b82f6", weight:5, opacity:.85,
dashArray:null, lineCap:"round", lineJoin:"round"}}
}}).addTo(map);
}}
}}).catch(function(){{}});
}}
// Geocode pickup first, then dropoff
geocode(pickupAddr, function(plat, plon, pname){{
if(plat !== null){{
pickupMarker = L.marker([plat, plon], {{icon:pickupIcon}})
.addTo(map)
.bindPopup("<b>📍 Pickup: {esc(v["pickup_name"])}</b><br/>" + (pname||pickupAddr));
geocode(dropoffAddr, function(dlat, dlon, dname){{
if(dlat !== null){{
dropoffMarker = L.marker([dlat, dlon], {{icon:dropoffIcon}})
.addTo(map)
.bindPopup("<b>🏠 Delivery: {esc(v["customer_name"])}</b><br/>" + (dname||dropoffAddr));
// Fit map to both markers
var bounds = L.latLngBounds([[plat,plon],[dlat,dlon]]);
map.fitBounds(bounds.pad(0.2));
// Draw driving route
drawRoute(plat, plon, dlat, dlon);
}} else {{
map.setView([plat, plon], 13);
pickupMarker.openPopup();
}}
}});
}} else {{
// Try just dropoff
geocode(dropoffAddr, function(dlat, dlon, dname){{
if(dlat !== null){{
dropoffMarker = L.marker([dlat, dlon], {{icon:dropoffIcon}}).addTo(map)
.bindPopup("<b>🏠 Delivery: {esc(v["customer_name"])}</b><br/>" + (dname||dropoffAddr));
map.setView([dlat, dlon], 13);
dropoffMarker.openPopup();
}} else {{
// Default world view
map.setView([20, 80], 4);
}}
}});
}}
}});
// ── Auto-refresh status every 30s ──────────────────────────────
var statusColors = {{
pending: ["#f59e0b", "⏳ Waiting for Driver"],
pickup: ["#3b82f6", "🚗 Driver at Restaurant"],
delivering: ["#06b6d4", "🛵 On the Way to You"],
delivered: ["#10b981", "✅ Order Delivered!"],
cancelled: ["#ef4444", "❌ Delivery Cancelled"],
}};
function refreshStatus(){{
fetch("/web/dataset/call_kw", {{
method:"POST",
headers:{{"Content-Type":"application/json"}},
body:JSON.stringify({{
jsonrpc:"2.0",method:"call",id:1,
params:{{
model: modelType + ".order",
method:"read",
args:[[orderId],["uber_status","uber_tracking_url","uber_eta"]],
kwargs:{{}}
}}
}})
}}).then(function(r){{return r.json();}}).then(function(d){{
if(!d.result || !d.result[0]) return;
var rec = d.result[0];
var status = rec.uber_status || "";
var info = statusColors[status] || ["#64748b", "❓ Unknown"];
var bar = document.getElementById("status_bar");
var dot = document.getElementById("status_dot");
var lbl = document.getElementById("status_label");
if(bar){{
bar.style.borderColor = info[0] + "33";
bar.style.background = info[0] + "18";
bar.style.color = info[0];
}}
if(dot) dot.style.background = info[0];
if(lbl) lbl.textContent = info[1];
if(["delivered","cancelled"].includes(status)) clearInterval(timer);
}}).catch(function(){{}});
}}
var timer = setInterval(refreshStatus, 30000);
}})();
</script>
</body>
</html>'''