Phase 1: Add Google Timeline import wizard
This commit is contained in:
197
work_trace/wizards/wt_import_timeline_wizard.py
Normal file
197
work_trace/wizards/wt_import_timeline_wizard.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
|
from math import radians, sin, cos, sqrt, atan2
|
||||||
|
|
||||||
|
STILL_ACTIVITIES = {'STILL', 'UNKNOWN', 'TILTING', 'EXITING_VEHICLE'}
|
||||||
|
VEHICLE_ACTIVITIES = {'IN_VEHICLE', 'IN_ROAD_VEHICLE', 'IN_RAIL_VEHICLE', 'IN_TWO_WHEELER_VEHICLE'}
|
||||||
|
WALKING_ACTIVITIES = {'WALKING', 'ON_FOOT', 'RUNNING', 'ON_BICYCLE'}
|
||||||
|
|
||||||
|
|
||||||
|
def _haversine_miles(lat1, lon1, lat2, lon2):
|
||||||
|
R = 3958.8
|
||||||
|
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
||||||
|
dlat, dlon = lat2 - lat1, lon2 - lon1
|
||||||
|
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
||||||
|
return R * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_travel_mode(activity_type):
|
||||||
|
if activity_type in VEHICLE_ACTIVITIES:
|
||||||
|
return 'driving'
|
||||||
|
if activity_type in WALKING_ACTIVITIES:
|
||||||
|
return 'walking'
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
class WtImportTimelineWizard(models.TransientModel):
|
||||||
|
_name = 'wt.import.timeline.wizard'
|
||||||
|
_description = 'Import Google Timeline'
|
||||||
|
|
||||||
|
timeline_file = fields.Binary(string='Timeline JSON File', required=True)
|
||||||
|
timeline_filename = fields.Char(string='Filename')
|
||||||
|
date_from = fields.Date(string='Date From')
|
||||||
|
date_to = fields.Date(string='Date To')
|
||||||
|
min_stop_minutes = fields.Integer(
|
||||||
|
string='Minimum Stop Duration (minutes)',
|
||||||
|
default=5,
|
||||||
|
help='Ignore stops shorter than this duration'
|
||||||
|
)
|
||||||
|
geocode = fields.Boolean(
|
||||||
|
string='Resolve Addresses via OpenStreetMap',
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_import(self):
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(self.timeline_file)
|
||||||
|
data = json.loads(raw)
|
||||||
|
except Exception as e:
|
||||||
|
raise UserError(_('Invalid JSON file: %s') % str(e))
|
||||||
|
|
||||||
|
stops = self._parse_timeline(data)
|
||||||
|
if not stops:
|
||||||
|
raise UserError(_('No location stops found in the uploaded file.'))
|
||||||
|
|
||||||
|
# Filter by date range
|
||||||
|
if self.date_from:
|
||||||
|
stops = [s for s in stops if s['arrived_at'].date() >= self.date_from]
|
||||||
|
if self.date_to:
|
||||||
|
stops = [s for s in stops if s['arrived_at'].date() <= self.date_to]
|
||||||
|
|
||||||
|
# Filter by minimum stop duration
|
||||||
|
min_secs = self.min_stop_minutes * 60
|
||||||
|
stops = [s for s in stops
|
||||||
|
if (s['departed_at'] - s['arrived_at']).total_seconds() >= min_secs]
|
||||||
|
|
||||||
|
if not stops:
|
||||||
|
raise UserError(_('No stops found matching the selected filters.'))
|
||||||
|
|
||||||
|
# Compute distances and travel times between consecutive stops
|
||||||
|
for i, stop in enumerate(stops):
|
||||||
|
if i > 0:
|
||||||
|
prev = stops[i - 1]
|
||||||
|
stop['distance_from_previous'] = _haversine_miles(
|
||||||
|
prev['lat'], prev['lng'], stop['lat'], stop['lng']
|
||||||
|
)
|
||||||
|
travel_delta = stop['arrived_at'] - prev['departed_at']
|
||||||
|
stop['travel_time_from_previous'] = max(
|
||||||
|
travel_delta.total_seconds() / 3600, 0.0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stop['distance_from_previous'] = 0.0
|
||||||
|
stop['travel_time_from_previous'] = 0.0
|
||||||
|
|
||||||
|
# Create wt.location.log records
|
||||||
|
LocationLog = self.env['wt.location.log']
|
||||||
|
created_ids = []
|
||||||
|
for stop in stops:
|
||||||
|
arrived = stop['arrived_at'].replace(tzinfo=None)
|
||||||
|
departed = stop['departed_at'].replace(tzinfo=None)
|
||||||
|
log = LocationLog.create({
|
||||||
|
'date': arrived.date(),
|
||||||
|
'arrived_at': arrived,
|
||||||
|
'departed_at': departed,
|
||||||
|
'latitude': stop['lat'],
|
||||||
|
'longitude': stop['lng'],
|
||||||
|
'travel_mode': stop.get('travel_mode', 'unknown'),
|
||||||
|
'distance_from_previous': stop['distance_from_previous'],
|
||||||
|
'travel_time_from_previous': stop['travel_time_from_previous'],
|
||||||
|
'source': 'google_timeline',
|
||||||
|
})
|
||||||
|
created_ids.append(log.id)
|
||||||
|
|
||||||
|
created = LocationLog.browse(created_ids)
|
||||||
|
|
||||||
|
if self.geocode:
|
||||||
|
created.action_geocode()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'name': _('Imported Location Logs (%d stops)') % len(created_ids),
|
||||||
|
'res_model': 'wt.location.log',
|
||||||
|
'view_mode': 'list,form',
|
||||||
|
'domain': [('id', 'in', created_ids)],
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_timeline(self, data):
|
||||||
|
positions = []
|
||||||
|
activities = []
|
||||||
|
|
||||||
|
for entry in data.get('timelineEdits', []):
|
||||||
|
raw = entry.get('rawSignal', {}).get('signal', {})
|
||||||
|
|
||||||
|
if 'position' in raw:
|
||||||
|
pos = raw['position']
|
||||||
|
point = pos.get('point', {})
|
||||||
|
lat = point.get('latE7', 0) / 1e7
|
||||||
|
lng = point.get('lngE7', 0) / 1e7
|
||||||
|
speed = pos.get('speedMetersPerSecond') or 0.0
|
||||||
|
ts_str = pos.get('timestamp', '')
|
||||||
|
if ts_str and lat and lng:
|
||||||
|
ts = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
|
||||||
|
positions.append({'ts': ts, 'lat': lat, 'lng': lng, 'speed': speed})
|
||||||
|
|
||||||
|
elif 'activityRecord' in raw:
|
||||||
|
ar = raw['activityRecord']
|
||||||
|
ts_str = ar.get('timestamp', '')
|
||||||
|
acts = ar.get('detectedActivities', [])
|
||||||
|
if ts_str and acts:
|
||||||
|
ts = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
|
||||||
|
dominant = max(acts, key=lambda x: x.get('probability', 0))
|
||||||
|
activities.append({'ts': ts, 'type': dominant['activityType']})
|
||||||
|
|
||||||
|
if not positions:
|
||||||
|
return []
|
||||||
|
|
||||||
|
positions.sort(key=lambda x: x['ts'])
|
||||||
|
activities.sort(key=lambda x: x['ts'])
|
||||||
|
|
||||||
|
def get_activity_at(ts):
|
||||||
|
if not activities:
|
||||||
|
return 'UNKNOWN'
|
||||||
|
nearest = min(activities, key=lambda a: abs((a['ts'] - ts).total_seconds()))
|
||||||
|
return nearest['type']
|
||||||
|
|
||||||
|
# Cluster consecutive STILL positions into stops
|
||||||
|
stops = []
|
||||||
|
current_stop = []
|
||||||
|
last_travel_mode = 'unknown'
|
||||||
|
|
||||||
|
for pos in positions:
|
||||||
|
activity = get_activity_at(pos['ts'])
|
||||||
|
is_still = activity in STILL_ACTIVITIES or pos['speed'] < 0.5
|
||||||
|
|
||||||
|
if is_still:
|
||||||
|
current_stop.append(pos)
|
||||||
|
else:
|
||||||
|
last_travel_mode = _get_travel_mode(activity)
|
||||||
|
if len(current_stop) >= 2:
|
||||||
|
avg_lat = sum(p['lat'] for p in current_stop) / len(current_stop)
|
||||||
|
avg_lng = sum(p['lng'] for p in current_stop) / len(current_stop)
|
||||||
|
stops.append({
|
||||||
|
'arrived_at': current_stop[0]['ts'],
|
||||||
|
'departed_at': current_stop[-1]['ts'],
|
||||||
|
'lat': avg_lat,
|
||||||
|
'lng': avg_lng,
|
||||||
|
'travel_mode': last_travel_mode,
|
||||||
|
})
|
||||||
|
current_stop = []
|
||||||
|
|
||||||
|
# Handle last stop
|
||||||
|
if len(current_stop) >= 2:
|
||||||
|
avg_lat = sum(p['lat'] for p in current_stop) / len(current_stop)
|
||||||
|
avg_lng = sum(p['lng'] for p in current_stop) / len(current_stop)
|
||||||
|
stops.append({
|
||||||
|
'arrived_at': current_stop[0]['ts'],
|
||||||
|
'departed_at': current_stop[-1]['ts'],
|
||||||
|
'lat': avg_lat,
|
||||||
|
'lng': avg_lng,
|
||||||
|
'travel_mode': last_travel_mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return stops
|
||||||
Reference in New Issue
Block a user