diff --git a/work_trace/wizards/wt_import_timeline_wizard.py b/work_trace/wizards/wt_import_timeline_wizard.py new file mode 100644 index 00000000..f22f618f --- /dev/null +++ b/work_trace/wizards/wt_import_timeline_wizard.py @@ -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