Add server file path import mode to bypass upload size limits
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
tests / Detect unreleased dependencies (push) Has been cancelled
tests / test with OCB (push) Has been cancelled
tests / test with Odoo (push) Has been cancelled

This commit is contained in:
2026-03-14 02:24:14 +00:00
parent a49fbbede7
commit 34f6345486

View File

@@ -2,6 +2,7 @@ from odoo import models, fields, api, _
from odoo.exceptions import UserError
import json
import base64
import os
import re
from datetime import datetime, timedelta
from math import radians, sin, cos, sqrt, atan2
@@ -41,7 +42,6 @@ def _get_travel_mode(activity_type):
def _parse_latlng(latlng_str):
"""Parse coordinate string like '30.0381046 deg, -95.5899101 deg' handling encoding issues."""
nums = re.findall(r'-?\d+\.\d+', latlng_str)
if len(nums) >= 2:
return float(nums[0]), float(nums[1])
@@ -49,7 +49,6 @@ def _parse_latlng(latlng_str):
def _parse_ts(ts_str):
"""Parse ISO 8601 timestamp to naive datetime."""
if not ts_str:
return None
try:
@@ -62,17 +61,25 @@ class WtImportTimelineWizard(models.TransientModel):
_name = 'wt.import.timeline.wizard'
_description = 'Import Google Timeline'
timeline_file = fields.Binary(string='Timeline JSON File', required=True)
import_mode = fields.Selection([
('upload', 'Upload File'),
('server_path', 'Server File Path'),
], string='Import Mode', default='upload', required=True)
timeline_file = fields.Binary(string='Timeline JSON File')
timeline_filename = fields.Char(string='Filename')
server_file_path = fields.Char(
string='Server File Path',
help='Full path to Timeline.json on the Odoo server (bypasses upload size limits)'
)
min_stop_minutes = fields.Integer(
string='Minimum Stop Duration (minutes)',
default=5,
help='Ignore stops shorter than this duration'
)
proximity_meters = fields.Integer(
string='Location Proximity (meters)',
default=200,
help='For raw signal files only: GPS positions within this distance are grouped as one location'
help='For Timeline Edits.json only: GPS positions within this distance are grouped as one location'
)
geocode = fields.Boolean(
string='Resolve Addresses via OpenStreetMap',
@@ -81,11 +88,27 @@ class WtImportTimelineWizard(models.TransientModel):
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))
# Load data from file upload or server path
if self.import_mode == 'server_path':
if not self.server_file_path:
raise UserError(_('Please enter the server file path.'))
path = self.server_file_path.strip()
if not os.path.exists(path):
raise UserError(_('File not found on server: %s') % path)
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
except Exception as e:
raise UserError(_('Error reading file: %s') % str(e))
else:
if not self.timeline_file:
raise UserError(_('Please upload a Timeline JSON file.'))
try:
raw = base64.b64decode(self.timeline_file)
data = json.loads(raw)
except Exception as e:
raise UserError(_('Invalid JSON file: %s') % str(e))
# Auto-detect format
if 'semanticSegments' in data:
@@ -96,7 +119,7 @@ class WtImportTimelineWizard(models.TransientModel):
raise UserError(_('Unrecognized format. Expected Timeline.json (semanticSegments) or Timeline Edits.json (timelineEdits).'))
if not stops:
raise UserError(_('No location stops found in the uploaded file.'))
raise UserError(_('No location stops found in the file.'))
# Filter by minimum stop duration
min_secs = self.min_stop_minutes * 60
@@ -109,16 +132,14 @@ class WtImportTimelineWizard(models.TransientModel):
stops.sort(key=lambda s: s['arrived_at'])
# Compute distances and travel times between consecutive stops
# Compute distances and travel times
for i, stop in enumerate(stops):
if i > 0:
prev = stops[i - 1]
if prev.get('lat') and stop.get('lat'):
stop['distance_from_previous'] = _haversine_miles(
prev['lat'], prev['lng'], stop['lat'], stop['lng']
)
else:
stop['distance_from_previous'] = 0.0
stop['distance_from_previous'] = (
_haversine_miles(prev['lat'], prev['lng'], stop['lat'], stop['lng'])
if prev.get('lat') and stop.get('lat') else 0.0
)
travel_delta = stop['arrived_at'] - prev['departed_at']
stop['travel_time_from_previous'] = max(travel_delta.total_seconds() / 3600, 0.0)
else:
@@ -140,7 +161,6 @@ class WtImportTimelineWizard(models.TransientModel):
if arrived_str in existing:
skipped += 1
continue
log = LocationLog.create({
'date': stop['arrived_at'].date(),
'arrived_at': stop['arrived_at'],
@@ -174,31 +194,22 @@ class WtImportTimelineWizard(models.TransientModel):
}
def _parse_semantic_timeline(self, data):
"""
Parse Timeline.json semanticSegments format.
Only 'visit' segments are location stops.
'timelinePath' segments are travel (ignored — distance calculated from stop coords).
"""
stops = []
for seg in data.get('semanticSegments', []):
visit = seg.get('visit')
if not visit:
continue
start_ts = _parse_ts(seg.get('startTime'))
end_ts = _parse_ts(seg.get('endTime'))
if not start_ts or not end_ts:
continue
candidate = visit.get('topCandidate', {})
semantic_type = candidate.get('semanticType', '')
latlng_str = candidate.get('placeLocation', {}).get('latLng', '')
lat, lng = _parse_latlng(latlng_str) if latlng_str else (None, None)
category = SEMANTIC_TYPE_CATEGORY.get(semantic_type, '')
if not category and semantic_type:
category = semantic_type.replace('_', ' ').title()
stops.append({
'arrived_at': start_ts,
'departed_at': end_ts,
@@ -208,14 +219,11 @@ class WtImportTimelineWizard(models.TransientModel):
'category': category,
'travel_mode': 'unknown',
})
return stops
def _parse_raw_timeline(self, data, proximity_meters=200):
"""Parse Timeline Edits.json raw signal format using proximity clustering."""
positions = []
activities = []
for entry in data.get('timelineEdits', []):
raw = entry.get('rawSignal', {}).get('signal', {})
if 'position' in raw:
@@ -251,7 +259,6 @@ class WtImportTimelineWizard(models.TransientModel):
stops = []
current_cluster = [positions[0]]
for pos in positions[1:]:
prev = current_cluster[-1]
if _distance_meters(prev['lat'], prev['lng'], pos['lat'], pos['lng']) <= proximity_meters: