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 from odoo.exceptions import UserError
import json import json
import base64 import base64
import os
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from math import radians, sin, cos, sqrt, atan2 from math import radians, sin, cos, sqrt, atan2
@@ -41,7 +42,6 @@ def _get_travel_mode(activity_type):
def _parse_latlng(latlng_str): 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) nums = re.findall(r'-?\d+\.\d+', latlng_str)
if len(nums) >= 2: if len(nums) >= 2:
return float(nums[0]), float(nums[1]) return float(nums[0]), float(nums[1])
@@ -49,7 +49,6 @@ def _parse_latlng(latlng_str):
def _parse_ts(ts_str): def _parse_ts(ts_str):
"""Parse ISO 8601 timestamp to naive datetime."""
if not ts_str: if not ts_str:
return None return None
try: try:
@@ -62,17 +61,25 @@ class WtImportTimelineWizard(models.TransientModel):
_name = 'wt.import.timeline.wizard' _name = 'wt.import.timeline.wizard'
_description = 'Import Google Timeline' _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') 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( min_stop_minutes = fields.Integer(
string='Minimum Stop Duration (minutes)', string='Minimum Stop Duration (minutes)',
default=5, default=5,
help='Ignore stops shorter than this duration'
) )
proximity_meters = fields.Integer( proximity_meters = fields.Integer(
string='Location Proximity (meters)', string='Location Proximity (meters)',
default=200, 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( geocode = fields.Boolean(
string='Resolve Addresses via OpenStreetMap', string='Resolve Addresses via OpenStreetMap',
@@ -81,11 +88,27 @@ class WtImportTimelineWizard(models.TransientModel):
def action_import(self): def action_import(self):
self.ensure_one() self.ensure_one()
try:
raw = base64.b64decode(self.timeline_file) # Load data from file upload or server path
data = json.loads(raw) if self.import_mode == 'server_path':
except Exception as e: if not self.server_file_path:
raise UserError(_('Invalid JSON file: %s') % str(e)) 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 # Auto-detect format
if 'semanticSegments' in data: 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).')) raise UserError(_('Unrecognized format. Expected Timeline.json (semanticSegments) or Timeline Edits.json (timelineEdits).'))
if not stops: 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 # Filter by minimum stop duration
min_secs = self.min_stop_minutes * 60 min_secs = self.min_stop_minutes * 60
@@ -109,16 +132,14 @@ class WtImportTimelineWizard(models.TransientModel):
stops.sort(key=lambda s: s['arrived_at']) 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): for i, stop in enumerate(stops):
if i > 0: if i > 0:
prev = stops[i - 1] prev = stops[i - 1]
if prev.get('lat') and stop.get('lat'): stop['distance_from_previous'] = (
stop['distance_from_previous'] = _haversine_miles( _haversine_miles(prev['lat'], prev['lng'], stop['lat'], stop['lng'])
prev['lat'], prev['lng'], stop['lat'], stop['lng'] if prev.get('lat') and stop.get('lat') else 0.0
) )
else:
stop['distance_from_previous'] = 0.0
travel_delta = stop['arrived_at'] - prev['departed_at'] travel_delta = stop['arrived_at'] - prev['departed_at']
stop['travel_time_from_previous'] = max(travel_delta.total_seconds() / 3600, 0.0) stop['travel_time_from_previous'] = max(travel_delta.total_seconds() / 3600, 0.0)
else: else:
@@ -140,7 +161,6 @@ class WtImportTimelineWizard(models.TransientModel):
if arrived_str in existing: if arrived_str in existing:
skipped += 1 skipped += 1
continue continue
log = LocationLog.create({ log = LocationLog.create({
'date': stop['arrived_at'].date(), 'date': stop['arrived_at'].date(),
'arrived_at': stop['arrived_at'], 'arrived_at': stop['arrived_at'],
@@ -174,31 +194,22 @@ class WtImportTimelineWizard(models.TransientModel):
} }
def _parse_semantic_timeline(self, data): 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 = [] stops = []
for seg in data.get('semanticSegments', []): for seg in data.get('semanticSegments', []):
visit = seg.get('visit') visit = seg.get('visit')
if not visit: if not visit:
continue continue
start_ts = _parse_ts(seg.get('startTime')) start_ts = _parse_ts(seg.get('startTime'))
end_ts = _parse_ts(seg.get('endTime')) end_ts = _parse_ts(seg.get('endTime'))
if not start_ts or not end_ts: if not start_ts or not end_ts:
continue continue
candidate = visit.get('topCandidate', {}) candidate = visit.get('topCandidate', {})
semantic_type = candidate.get('semanticType', '') semantic_type = candidate.get('semanticType', '')
latlng_str = candidate.get('placeLocation', {}).get('latLng', '') latlng_str = candidate.get('placeLocation', {}).get('latLng', '')
lat, lng = _parse_latlng(latlng_str) if latlng_str else (None, None) lat, lng = _parse_latlng(latlng_str) if latlng_str else (None, None)
category = SEMANTIC_TYPE_CATEGORY.get(semantic_type, '') category = SEMANTIC_TYPE_CATEGORY.get(semantic_type, '')
if not category and semantic_type: if not category and semantic_type:
category = semantic_type.replace('_', ' ').title() category = semantic_type.replace('_', ' ').title()
stops.append({ stops.append({
'arrived_at': start_ts, 'arrived_at': start_ts,
'departed_at': end_ts, 'departed_at': end_ts,
@@ -208,14 +219,11 @@ class WtImportTimelineWizard(models.TransientModel):
'category': category, 'category': category,
'travel_mode': 'unknown', 'travel_mode': 'unknown',
}) })
return stops return stops
def _parse_raw_timeline(self, data, proximity_meters=200): def _parse_raw_timeline(self, data, proximity_meters=200):
"""Parse Timeline Edits.json raw signal format using proximity clustering."""
positions = [] positions = []
activities = [] activities = []
for entry in data.get('timelineEdits', []): for entry in data.get('timelineEdits', []):
raw = entry.get('rawSignal', {}).get('signal', {}) raw = entry.get('rawSignal', {}).get('signal', {})
if 'position' in raw: if 'position' in raw:
@@ -251,7 +259,6 @@ class WtImportTimelineWizard(models.TransientModel):
stops = [] stops = []
current_cluster = [positions[0]] current_cluster = [positions[0]]
for pos in positions[1:]: for pos in positions[1:]:
prev = current_cluster[-1] prev = current_cluster[-1]
if _distance_meters(prev['lat'], prev['lng'], pos['lat'], pos['lng']) <= proximity_meters: if _distance_meters(prev['lat'], prev['lng'], pos['lat'], pos['lng']) <= proximity_meters: