Add server file path import mode to bypass upload size limits
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user