Compare commits
60 Commits
adbe430761
...
18.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d1ea26dba1 | |||
| 845365d5b0 | |||
| a72d9f8c1b | |||
| 69194f81eb | |||
| d826665de5 | |||
| 59eda16782 | |||
| 71805569ab | |||
| 599ad94d2a | |||
| f719a668d6 | |||
| fb5d950733 | |||
| 2406fc9aa8 | |||
| 1ccc83a64e | |||
| 67449c3047 | |||
| fa177e0342 | |||
| 1b796b6ba4 | |||
| 334a1a0f88 | |||
| cc8bd4ef00 | |||
| 988d26df4e | |||
| 7c65a34fce | |||
| aa714a8fd6 | |||
| 2b678ff1be | |||
| 82309f80fb | |||
| 5bcd8a5548 | |||
| d242700b88 | |||
| 34f6345486 | |||
| a49fbbede7 | |||
| 37b3540eb8 | |||
| b57b2e75ab | |||
| 7351c558ce | |||
| 0019199ed8 | |||
| 80ae631d9c | |||
| bed328abbc | |||
| 84657dde00 | |||
| aa11316dc6 | |||
| 86e13bab51 | |||
| 5043ae7cc6 | |||
| ab2efe4e3a | |||
| 86cb6945c2 | |||
| 343bc95e6a | |||
| 49b462628e | |||
| ab9ba5a15c | |||
| 6410802bb5 | |||
| 4d2c9f3021 | |||
| 26248113b7 | |||
| 4a811f8666 | |||
| 727e6ea096 | |||
| 0d2bf1177c | |||
| 83c6be73e4 | |||
| a6473957f2 | |||
| 3d15443c46 | |||
| 776b70bcce | |||
| 9b3abfa5df | |||
| d61ca8bc2a | |||
| 738c3c4f8f | |||
| 555e01380b | |||
| 63d9ba40ed | |||
| d3722033be | |||
| 7e7d2831cf | |||
|
|
400df898e3 | ||
|
|
44be28c087 |
337
work_trace/SPEC.md
Normal file
337
work_trace/SPEC.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# WorkTrace — Odoo Module Specification
|
||||
|
||||
**Module Name:** `work_trace`
|
||||
**Display Name:** WorkTrace
|
||||
**Version:** 18.0.1.0.0
|
||||
**Category:** Productivity / Field Operations
|
||||
**Author:** tocmo0nlord
|
||||
**License:** LGPL-3
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
WorkTrace is a custom Odoo 18 module designed to consolidate and organize daily field work activities into a single unified calendar view. It captures location history, travel distances, time spent at locations, credit card expenses, and receipts — all mapped to a daily work journal. The organized data will serve as the foundation for future integration with Autotask (PSA) for timecard submission, expense reporting, and PO/project reconciliation.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. Eliminate manual data entry across multiple platforms
|
||||
2. Build a single source of truth for daily work activities inside Odoo
|
||||
3. Enable accurate expense and timecard submissions tied to project/ticket numbers
|
||||
4. Lay the groundwork for future Autotask API integration
|
||||
|
||||
---
|
||||
|
||||
## Feature Set
|
||||
|
||||
---
|
||||
|
||||
### Feature 1: Location & Travel Tracking
|
||||
|
||||
**Source:** Google Timeline (GPS coordinates)
|
||||
|
||||
#### Description
|
||||
Import location history from Google Timeline to reconstruct the user's daily travel. Each location pinpoint is translated into a human-readable address or business name and displayed on the Odoo calendar.
|
||||
|
||||
#### Capabilities
|
||||
- Import Google Timeline data (JSON export or API)
|
||||
- Translate GPS coordinates to street address / business name via OpenStreetMap Nominatim
|
||||
- Calculate distance traveled between each location stop
|
||||
- Calculate time spent at each location
|
||||
- Toggle calendar integration ON/OFF per day or globally
|
||||
|
||||
#### Data Points Captured
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| Timestamp | Date and time of location pinpoint |
|
||||
| Coordinates | Latitude / Longitude |
|
||||
| Address | Resolved street address |
|
||||
| Business Name | Resolved place name (if applicable) |
|
||||
| Time at Location | Duration spent at location |
|
||||
| Distance from Previous | Calculated travel distance (miles/km) |
|
||||
| Travel Mode | Driving / Walking / Transit (if available from Timeline) |
|
||||
|
||||
#### Calendar Integration
|
||||
- Each location stop creates a calendar event block
|
||||
- Events show: address, time spent, distance traveled
|
||||
- Color-coded by location type (office, client site, hotel, transit)
|
||||
- Toggle: Enable/Disable location events on calendar
|
||||
|
||||
---
|
||||
|
||||
### Feature 2: Credit Card Expense Tracking
|
||||
|
||||
**Source:** Citi Card financial statements
|
||||
|
||||
#### Description
|
||||
Import daily credit card transactions from Citi Card statements and map them to the calendar. The user reviews each transaction and accepts, modifies, or rejects it before it is finalized as an expense entry.
|
||||
|
||||
#### Capabilities
|
||||
- Import Citi Card statements (CSV, OFX, or PDF format)
|
||||
- Parse transactions: date, merchant, amount, category
|
||||
- Map each transaction to the corresponding calendar day
|
||||
- Present a review queue: Accept / Modify / Reject per transaction
|
||||
- Accepted transactions become Odoo expense entries
|
||||
- Link expenses to projects / Autotask ticket numbers
|
||||
- Support for multi-currency transactions
|
||||
|
||||
#### Transaction Review Workflow
|
||||
Import Statement -> Parse Transactions -> Review Queue
|
||||
-> Accept -> Create Expense Entry -> Link to Project/PO
|
||||
-> Modify -> Edit details -> Create Expense Entry
|
||||
-> Reject -> Archive (excluded from reporting)
|
||||
|
||||
#### Data Points Captured
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| Transaction Date | Date of purchase |
|
||||
| Merchant Name | Vendor / store name |
|
||||
| Amount | Transaction amount |
|
||||
| Currency | Transaction currency |
|
||||
| Category | Auto-categorized (fuel, meals, lodging, etc.) |
|
||||
| Status | Pending / Accepted / Rejected |
|
||||
| Project Link | Autotask ticket / PO number |
|
||||
|
||||
---
|
||||
|
||||
### Feature 3: Receipt Management
|
||||
|
||||
**Source:** Synology NAS photo library + Work email
|
||||
|
||||
#### 3A — Photo Receipts (Synology)
|
||||
|
||||
##### Description
|
||||
Receipt photos stored on Synology NAS are scanned, and relevant data is extracted using OCR. Receipt date is cross-referenced with photo EXIF data (receipt date takes priority). Receipts are matched to the corresponding calendar day and linked to expense entries.
|
||||
|
||||
##### Capabilities
|
||||
- Connect to Synology NAS via API or mapped folder
|
||||
- Monitor designated receipt folder for new uploads
|
||||
- Extract receipt data via EasyOCR (GPU-accelerated, NVIDIA 5080 + CUDA):
|
||||
- Vendor name
|
||||
- Date of purchase
|
||||
- Total amount
|
||||
- Line items (if legible)
|
||||
- Cross-reference: Receipt date (primary) vs. EXIF date (secondary)
|
||||
- Match receipt to calendar day and existing expense entry
|
||||
- Manual override: reassign receipt to different date/expense
|
||||
- Preview receipt image inside Odoo calendar/expense view
|
||||
|
||||
##### Date Priority Logic
|
||||
IF receipt_date extracted by OCR -> use receipt_date
|
||||
ELSE IF EXIF date available -> use EXIF date
|
||||
ELSE -> flag for manual date assignment
|
||||
|
||||
#### 3B — Email Receipts (Hotels / Flights / Car Rentals)
|
||||
|
||||
##### Description
|
||||
Receipts for hotels, flights, and car rentals are received via work email. These are automatically scanned, parsed, and added to the calendar as travel expense entries.
|
||||
|
||||
##### Email Ingestion Method
|
||||
Dedicated forwarding email monitored by Odoo via Fetchmail (IMAP).
|
||||
Forward hotel/flight/car rental confirmations to a designated address (e.g., receipts@yourdomain).
|
||||
Odoo polls this inbox and parses incoming emails automatically.
|
||||
|
||||
##### Data Points Captured
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| Vendor | Hotel / Airline / Car rental company |
|
||||
| Confirmation # | Booking reference |
|
||||
| Check-in / Check-out | For hotels |
|
||||
| Flight dates | Departure / Arrival |
|
||||
| Total Amount | Cost of booking |
|
||||
| Dates | Matched to calendar |
|
||||
|
||||
---
|
||||
|
||||
### Feature 4: Daily Work Journal (Calendar Hub)
|
||||
|
||||
#### Description
|
||||
The Odoo calendar becomes the central hub for all WorkTrace data. Each day displays a consolidated view of:
|
||||
- Location stops and travel
|
||||
- Time at each location
|
||||
- Credit card transactions
|
||||
- Receipts (photo + email)
|
||||
- Manual time entries (timecard)
|
||||
|
||||
#### Capabilities
|
||||
- Unified day view: location + expenses + receipts
|
||||
- Manual time entry per project/ticket
|
||||
- Link any calendar event to an Autotask ticket number
|
||||
- Daily summary: total travel distance, total expenses, total hours
|
||||
- Notes/comments per day
|
||||
- Export day summary to PDF or Excel
|
||||
|
||||
---
|
||||
|
||||
### Feature 5: Timecard Management
|
||||
|
||||
#### Description
|
||||
Track hours worked per day, linked to Autotask project ticket numbers. Time entries are auto-suggested from location data (time at client site) and can be manually adjusted.
|
||||
|
||||
#### Capabilities
|
||||
- Auto-suggest time entries from location stops
|
||||
- Manual time entry: project, ticket number, description, hours
|
||||
- Daily/weekly timecard view
|
||||
- Submit timecard for approval workflow
|
||||
- Export timecard (CSV, PDF) for Autotask submission
|
||||
|
||||
---
|
||||
|
||||
### Feature 6: Project & PO Linking
|
||||
|
||||
#### Description
|
||||
All expenses, time entries, and receipts can be linked to a project identified by an Autotask ticket number. This organizes all daily activities under their respective projects.
|
||||
|
||||
#### Capabilities
|
||||
- Autotask ticket number field on all expense/time entries
|
||||
- Group expenses and time by ticket/project
|
||||
- Per-project expense summary
|
||||
- Per-project time summary
|
||||
- Future: Direct sync to Autotask via API
|
||||
|
||||
---
|
||||
|
||||
### Feature 7: Export & Reporting
|
||||
|
||||
#### Description
|
||||
Once data is organized, export it in formats suitable for submission or external system import.
|
||||
|
||||
#### Export Formats
|
||||
| Report | Format | Contents |
|
||||
|---|---|---|
|
||||
| Expense Report | PDF / Excel | All accepted expenses, grouped by project |
|
||||
| Timecard | PDF / Excel / CSV | Hours per project per day |
|
||||
| Travel Log | PDF | Locations, distances, times |
|
||||
| Daily Summary | PDF | Full day: location + expenses + time |
|
||||
| Autotask Import | CSV | Future: formatted for Autotask import |
|
||||
|
||||
---
|
||||
|
||||
## Additional Features Recommended
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| Mileage Reimbursement | Auto-calculate mileage reimbursement based on IRS standard rate |
|
||||
| Per Diem Tracking | Track daily meal/lodging allowances |
|
||||
| OCR Confidence Score | Flag low-confidence OCR extractions for manual review |
|
||||
| Duplicate Detection | Detect duplicate receipts or transactions |
|
||||
| Offline Mode | Queue entries when no internet, sync when back online |
|
||||
| Mobile Responsive | Full mobile UI for field use |
|
||||
| Notification System | Remind user to review pending transactions daily |
|
||||
| Audit Log | Track all changes to expense entries |
|
||||
| Manager Approval Workflow | Submit expense reports for manager sign-off |
|
||||
| Synology Webhook | Auto-trigger receipt scan when new photo is uploaded |
|
||||
|
||||
---
|
||||
|
||||
## Future Integration: Autotask (PSA)
|
||||
|
||||
> Phase 2 — Out of scope for initial development
|
||||
|
||||
- Connect to Autotask REST API
|
||||
- Push approved timecards to Autotask tickets
|
||||
- Push approved expenses to Autotask expense reports
|
||||
- Pull ticket/project list from Autotask for linking
|
||||
- Two-way sync: status updates reflected in Odoo
|
||||
|
||||
---
|
||||
|
||||
## Technical Stack
|
||||
|
||||
| Component | Technology |
|
||||
|---|---|
|
||||
| Backend | Odoo 18 (Python) |
|
||||
| Frontend | OWL (Odoo Web Library) |
|
||||
| Geocoding | OpenStreetMap Nominatim (free, no API key required) |
|
||||
| OCR | EasyOCR (GPU-accelerated via CUDA, NVIDIA 5080) |
|
||||
| Email Ingestion | Odoo Fetchmail (IMAP) — dedicated forwarding address |
|
||||
| Synology Integration | Synology NAS API / DSM |
|
||||
| Export | QWeb PDF / xlsxwriter |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Diagram
|
||||
|
||||
[Google Timeline] ──────────────────┐
|
||||
[Citi Card Statement] ───────────── │
|
||||
[Synology Receipt Photos] ──────── ►│ WorkTrace Module ──► [Odoo Calendar]
|
||||
[Work Email Receipts] ──────────── │ ──► [Expense Entries]
|
||||
[Manual Time Entry] ────────────── ┘ ──► [Timecard]
|
||||
──► [Export / Reports]
|
||||
──► [Autotask (Phase 2)]
|
||||
|
||||
---
|
||||
|
||||
## Module Structure (Planned)
|
||||
|
||||
work_trace/
|
||||
├── __init__.py
|
||||
├── __manifest__.py
|
||||
├── models/
|
||||
│ ├── wt_location_log.py
|
||||
│ ├── wt_expense_entry.py
|
||||
│ ├── wt_receipt.py
|
||||
│ ├── wt_timecard.py
|
||||
│ └── wt_project_link.py
|
||||
├── views/
|
||||
│ ├── wt_calendar_view.xml
|
||||
│ ├── wt_location_log_views.xml
|
||||
│ ├── wt_expense_entry_views.xml
|
||||
│ ├── wt_receipt_views.xml
|
||||
│ └── wt_timecard_views.xml
|
||||
├── wizards/
|
||||
│ ├── wt_import_timeline_wizard.py
|
||||
│ ├── wt_import_statement_wizard.py
|
||||
│ └── wt_export_report_wizard.py
|
||||
├── static/
|
||||
│ └── description/
|
||||
│ └── icon.png
|
||||
├── security/
|
||||
│ └── ir.model.access.csv
|
||||
├── data/
|
||||
│ └── wt_data.xml
|
||||
└── README.md
|
||||
|
||||
---
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1 — Core Foundation
|
||||
- [ ] Module scaffold and manifest
|
||||
- [ ] Location log model + Google Timeline import
|
||||
- [ ] Geocoding (coordinates → address)
|
||||
- [ ] Basic calendar integration
|
||||
|
||||
### Phase 2 — Expenses
|
||||
- [ ] Credit card statement import (Citi)
|
||||
- [ ] Transaction review workflow
|
||||
- [ ] Expense entry model
|
||||
|
||||
### Phase 3 — Receipts
|
||||
- [ ] Synology NAS connection
|
||||
- [ ] OCR receipt extraction (EasyOCR + GPU)
|
||||
- [ ] Email receipt ingestion (Fetchmail)
|
||||
- [ ] Receipt matching to expenses
|
||||
|
||||
### Phase 4 — Timecard & Projects
|
||||
- [ ] Timecard model
|
||||
- [ ] Autotask ticket number linking
|
||||
- [ ] Project-based grouping
|
||||
|
||||
### Phase 5 — Export & Reporting
|
||||
- [ ] PDF/Excel export
|
||||
- [ ] Expense report generation
|
||||
- [ ] Timecard export
|
||||
|
||||
### Phase 6 — Autotask Integration (Future)
|
||||
- [ ] Autotask REST API connection
|
||||
- [ ] Timecard push
|
||||
- [ ] Expense push
|
||||
- [ ] Ticket/project pull
|
||||
|
||||
---
|
||||
|
||||
*Generated: 2026-03-13*
|
||||
*Status: Planning*
|
||||
2
work_trace/__init__.py
Normal file
2
work_trace/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizards
|
||||
30
work_trace/__manifest__.py
Normal file
30
work_trace/__manifest__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
'name': 'WorkTrace',
|
||||
'version': '18.0.1.0.2',
|
||||
'category': 'Productivity',
|
||||
'summary': 'Daily work journal: location tracking, expenses, receipts, and timecards',
|
||||
'description': '''
|
||||
WorkTrace consolidates daily field work activities into a unified Odoo calendar.
|
||||
Tracks location history, travel distances, credit card expenses, receipts, and timecards.
|
||||
Designed for future integration with Autotask PSA.
|
||||
''',
|
||||
'author': 'tocmo0nlord',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'base',
|
||||
'calendar',
|
||||
'hr_expense',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/wt_geocode_cron.xml',
|
||||
'views/wt_location_log_views.xml',
|
||||
'views/wt_import_timeline_wizard_views.xml',
|
||||
'wizards/wt_date_range_wizard_views.xml',
|
||||
'views/wt_menu_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
0
work_trace/data/.gitkeep
Normal file
0
work_trace/data/.gitkeep
Normal file
26
work_trace/data/wt_geocode_cron.xml
Normal file
26
work_trace/data/wt_geocode_cron.xml
Normal file
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- Geocoding cron: resolves 20 ungeocoded records every 2 minutes.
|
||||
Auto-disables itself when all records have addresses.
|
||||
Re-enable via Settings > Technical > Scheduled Actions. -->
|
||||
<record id="ir_cron_geocode_location_logs" model="ir.cron">
|
||||
<field name="cron_name">WorkTrace: Geocode Location Logs (batch)</field>
|
||||
<field name="model_id" ref="model_wt_location_log"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
pending = env['wt.location.log'].search([('latitude', '!=', 0), ('address', '=', False)], limit=1)
|
||||
if pending:
|
||||
env['wt.location.log'].action_geocode_all_pending()
|
||||
else:
|
||||
env.ref('work_trace.ir_cron_geocode_location_logs').active = False
|
||||
</field>
|
||||
<field name="interval_number">2</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">10</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
0
work_trace/migrations/18.0.1.0.2/__init__.py
Normal file
0
work_trace/migrations/18.0.1.0.2/__init__.py
Normal file
0
work_trace/migrations/__init__.py
Normal file
0
work_trace/migrations/__init__.py
Normal file
1
work_trace/models/__init__.py
Normal file
1
work_trace/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import wt_location_log
|
||||
186
work_trace/models/wt_location_log.py
Normal file
186
work_trace/models/wt_location_log.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from odoo import models, fields, api, _
|
||||
from math import radians, sin, cos, sqrt, atan2
|
||||
|
||||
|
||||
CATEGORY_MAP = {
|
||||
'IN_PASSENGER_VEHICLE': 'In Vehicle',
|
||||
'IN_VEHICLE': 'In Vehicle',
|
||||
'IN_ROAD_VEHICLE': 'In Vehicle',
|
||||
'IN_RAIL_VEHICLE': 'Rail / Transit',
|
||||
'IN_TWO_WHEELER_VEHICLE': 'Motorcycle / Scooter',
|
||||
'WALKING': 'Walking',
|
||||
'ON_FOOT': 'Walking',
|
||||
'RUNNING': 'Running',
|
||||
'ON_BICYCLE': 'Cycling',
|
||||
'STILL': 'Stationary',
|
||||
'UNKNOWN': 'Unknown',
|
||||
}
|
||||
|
||||
GEOCODE_BATCH_SIZE = 20
|
||||
|
||||
|
||||
class WtLocationLog(models.Model):
|
||||
_name = 'wt.location.log'
|
||||
_description = 'WorkTrace Location Log'
|
||||
_order = 'arrived_at asc'
|
||||
|
||||
name = fields.Char(string='Name', compute='_compute_name', store=True)
|
||||
date = fields.Date(string='Date', required=True, index=True)
|
||||
arrived_at = fields.Datetime(string='Begin Time', required=True)
|
||||
departed_at = fields.Datetime(string='End Time')
|
||||
|
||||
latitude = fields.Float(string='Latitude', digits=(10, 7))
|
||||
longitude = fields.Float(string='Longitude', digits=(10, 7))
|
||||
address = fields.Char(string='Address')
|
||||
place_name = fields.Char(string='Place / Business Name')
|
||||
category = fields.Char(string='Category')
|
||||
|
||||
time_at_location = fields.Float(
|
||||
string='Duration (hrs)',
|
||||
compute='_compute_time_at_location',
|
||||
store=True
|
||||
)
|
||||
distance_from_previous = fields.Float(string='Distance (mi)', digits=(10, 2))
|
||||
travel_time_from_previous = fields.Float(string='Travel Time (hrs)', digits=(10, 2))
|
||||
travel_mode = fields.Selection([
|
||||
('driving', 'Driving'),
|
||||
('walking', 'Walking'),
|
||||
('cycling', 'Cycling'),
|
||||
('transit', 'Transit'),
|
||||
('flying', 'Flying'),
|
||||
('unknown', 'Unknown'),
|
||||
], string='Travel Mode', default='unknown')
|
||||
|
||||
source = fields.Selection([
|
||||
('google_timeline', 'Google Timeline'),
|
||||
('manual', 'Manual'),
|
||||
], string='Source', default='google_timeline')
|
||||
|
||||
calendar_event_id = fields.Many2one('calendar.event', string='Calendar Event', ondelete='set null')
|
||||
add_to_calendar = fields.Boolean(string='Add to Calendar', default=True)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
@api.depends('place_name', 'address', 'latitude', 'longitude')
|
||||
def _compute_name(self):
|
||||
for rec in self:
|
||||
rec.name = (
|
||||
rec.place_name
|
||||
or rec.address
|
||||
or (f'{rec.latitude:.5f}, {rec.longitude:.5f}' if rec.latitude else 'Unknown Location')
|
||||
)
|
||||
|
||||
@api.depends('arrived_at', 'departed_at')
|
||||
def _compute_time_at_location(self):
|
||||
for rec in self:
|
||||
if rec.arrived_at and rec.departed_at:
|
||||
rec.time_at_location = (rec.departed_at - rec.arrived_at).total_seconds() / 3600
|
||||
else:
|
||||
rec.time_at_location = 0.0
|
||||
|
||||
@staticmethod
|
||||
def _haversine_distance(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 action_geocode(self):
|
||||
"""Resolve coordinates to address using OpenStreetMap Nominatim.
|
||||
Processes up to GEOCODE_BATCH_SIZE records per call to avoid HTTP worker timeouts.
|
||||
"""
|
||||
import requests
|
||||
import time
|
||||
|
||||
to_geocode = self.filtered(lambda r: r.latitude and r.longitude and not r.address)
|
||||
if not to_geocode:
|
||||
to_geocode = self.filtered(lambda r: r.latitude and r.longitude)
|
||||
|
||||
batch = to_geocode[:GEOCODE_BATCH_SIZE]
|
||||
processed = 0
|
||||
|
||||
for rec in batch:
|
||||
url = 'https://nominatim.openstreetmap.org/reverse'
|
||||
params = {'lat': rec.latitude, 'lon': rec.longitude, 'format': 'json', 'addressdetails': 1}
|
||||
headers = {'User-Agent': 'WorkTrace/1.0 (Odoo Module)'}
|
||||
try:
|
||||
resp = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
addr = result.get('address', {})
|
||||
|
||||
rec.address = result.get('display_name', '')
|
||||
rec.place_name = rec.place_name or (
|
||||
result.get('name')
|
||||
or addr.get('amenity')
|
||||
or addr.get('shop')
|
||||
or addr.get('building')
|
||||
or addr.get('office')
|
||||
or addr.get('tourism')
|
||||
or ''
|
||||
)
|
||||
osm_type = (
|
||||
addr.get('amenity') or addr.get('shop') or addr.get('tourism')
|
||||
or addr.get('leisure') or addr.get('office') or addr.get('building')
|
||||
or result.get('type', '') or result.get('class', '')
|
||||
)
|
||||
if osm_type and not rec.category:
|
||||
rec.category = osm_type.replace('_', ' ').title()
|
||||
|
||||
processed += 1
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
remaining = self.search_count([('latitude', '!=', 0), ('address', '=', False)])
|
||||
|
||||
if remaining:
|
||||
msg = _('%d address(es) resolved. %d still need geocoding — click "Geocode Next %d" again to continue.') % (
|
||||
processed, remaining, GEOCODE_BATCH_SIZE)
|
||||
notif_type = 'warning'
|
||||
else:
|
||||
msg = _('All done! %d address(es) resolved.') % processed
|
||||
notif_type = 'success'
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Geocoding Batch Complete'),
|
||||
'message': msg,
|
||||
'type': notif_type,
|
||||
'sticky': True,
|
||||
},
|
||||
}
|
||||
|
||||
def action_geocode_all_pending(self):
|
||||
"""Geocode the next batch of ALL unresolved records (ignores selection)."""
|
||||
pending = self.search([('latitude', '!=', 0), ('address', '=', False)], limit=GEOCODE_BATCH_SIZE)
|
||||
if not pending:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Nothing to Geocode'),
|
||||
'message': _('All records with coordinates already have addresses.'),
|
||||
'type': 'info',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
return pending.action_geocode()
|
||||
|
||||
def action_compute_distances(self):
|
||||
"""Recompute distances and travel times for all logs grouped by date."""
|
||||
dates = self.mapped('date')
|
||||
for date in set(dates):
|
||||
logs = self.search([('date', '=', date)], order='arrived_at asc')
|
||||
prev = None
|
||||
for log in logs:
|
||||
if prev and prev.latitude and log.latitude:
|
||||
log.distance_from_previous = self._haversine_distance(
|
||||
prev.latitude, prev.longitude, log.latitude, log.longitude
|
||||
)
|
||||
if prev.departed_at and log.arrived_at:
|
||||
delta = log.arrived_at - prev.departed_at
|
||||
log.travel_time_from_previous = max(delta.total_seconds() / 3600, 0)
|
||||
prev = log
|
||||
0
work_trace/security/.gitkeep
Normal file
0
work_trace/security/.gitkeep
Normal file
4
work_trace/security/ir.model.access.csv
Normal file
4
work_trace/security/ir.model.access.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_wt_location_log,wt.location.log,model_wt_location_log,,1,1,1,1
|
||||
access_wt_import_timeline_wizard,access_wt_import_timeline_wizard,model_wt_import_timeline_wizard,base.group_user,1,1,1,1
|
||||
access_wt_date_range_wizard,access_wt_date_range_wizard,model_wt_date_range_wizard,base.group_user,1,1,1,1
|
||||
|
0
work_trace/static/description/.gitkeep
Normal file
0
work_trace/static/description/.gitkeep
Normal file
0
work_trace/views/.gitkeep
Normal file
0
work_trace/views/.gitkeep
Normal file
43
work_trace/views/wt_import_timeline_wizard_views.xml
Normal file
43
work_trace/views/wt_import_timeline_wizard_views.xml
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="wt_import_timeline_wizard_view_form" model="ir.ui.view">
|
||||
<field name="name">wt.import.timeline.wizard.form</field>
|
||||
<field name="model">wt.import.timeline.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Import Google Timeline">
|
||||
<group string="Source">
|
||||
<field name="import_mode" widget="radio"/>
|
||||
</group>
|
||||
<group string="File Upload" invisible="import_mode != 'upload'">
|
||||
<field name="timeline_file" filename="timeline_filename"/>
|
||||
<field name="timeline_filename" invisible="1"/>
|
||||
</group>
|
||||
<group string="Server File Path" invisible="import_mode != 'server_path'">
|
||||
<field name="server_file_path" placeholder="/home/tocmo0nlord/odoo/extra-addons/work_trace/Timeline.json"/>
|
||||
</group>
|
||||
<group string="Options">
|
||||
<field name="min_stop_minutes"/>
|
||||
<field name="proximity_meters"/>
|
||||
<field name="geocode"/>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert">
|
||||
All travel dates will be imported. Re-importing skips existing stops automatically.
|
||||
Supports both Timeline.json (from phone) and Timeline Edits.json (from Google Takeout).
|
||||
</div>
|
||||
<footer>
|
||||
<button name="action_import" string="Import" type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="wt_import_timeline_wizard_action" model="ir.actions.act_window">
|
||||
<field name="name">Import Google Timeline</field>
|
||||
<field name="res_model">wt.import.timeline.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
134
work_trace/views/wt_location_log_views.xml
Normal file
134
work_trace/views/wt_location_log_views.xml
Normal file
@@ -0,0 +1,134 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Server Action: Geocode Next 20 (appears in list view Action dropdown) -->
|
||||
<record id="action_geocode_next_batch" model="ir.actions.server">
|
||||
<field name="name">Geocode Next 20 Addresses</field>
|
||||
<field name="model_id" ref="model_wt_location_log"/>
|
||||
<field name="binding_model_id" ref="model_wt_location_log"/>
|
||||
<field name="binding_view_types">list,form</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = records.env['wt.location.log'].action_geocode_all_pending()</field>
|
||||
</record>
|
||||
|
||||
<!-- Server Action: Compute Distances (appears in list view Action dropdown) -->
|
||||
<record id="action_compute_distances_batch" model="ir.actions.server">
|
||||
<field name="name">Recompute Distances</field>
|
||||
<field name="model_id" ref="model_wt_location_log"/>
|
||||
<field name="binding_model_id" ref="model_wt_location_log"/>
|
||||
<field name="binding_view_types">list,form</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">records.action_compute_distances()</field>
|
||||
</record>
|
||||
|
||||
<!-- List View -->
|
||||
<record id="wt_location_log_view_list" model="ir.ui.view">
|
||||
<field name="name">wt.location.log.list</field>
|
||||
<field name="model">wt.location.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Location Logs" default_order="arrived_at asc">
|
||||
<field name="category"/>
|
||||
<field name="distance_from_previous" string="Distance (mi)"/>
|
||||
<field name="address"/>
|
||||
<field name="name" string="Name"/>
|
||||
<field name="arrived_at" string="Begin Time"/>
|
||||
<field name="departed_at" string="End Time"/>
|
||||
<field name="time_at_location" string="Duration (hrs)" widget="float_time"/>
|
||||
<field name="travel_mode" optional="show"/>
|
||||
<field name="date" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="wt_location_log_view_form" model="ir.ui.view">
|
||||
<field name="name">wt.location.log.form</field>
|
||||
<field name="model">wt.location.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Location Log">
|
||||
<header>
|
||||
<button name="action_geocode" string="Resolve Address" type="object" class="btn-primary"/>
|
||||
<button name="action_compute_distances" string="Compute Distances" type="object"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Location">
|
||||
<field name="date"/>
|
||||
<field name="category"/>
|
||||
<field name="place_name" string="Name"/>
|
||||
<field name="address"/>
|
||||
<field name="latitude"/>
|
||||
<field name="longitude"/>
|
||||
</group>
|
||||
<group string="Timing">
|
||||
<field name="arrived_at" string="Begin Time"/>
|
||||
<field name="departed_at" string="End Time"/>
|
||||
<field name="time_at_location" string="Duration (hrs)" widget="float_time"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Travel from Previous Stop">
|
||||
<field name="distance_from_previous" string="Distance (mi)"/>
|
||||
<field name="travel_time_from_previous" string="Travel Time (hrs)" widget="float_time"/>
|
||||
<field name="travel_mode"/>
|
||||
</group>
|
||||
<group string="Settings">
|
||||
<field name="source"/>
|
||||
<field name="add_to_calendar"/>
|
||||
<field name="calendar_event_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="wt_location_log_view_search" model="ir.ui.view">
|
||||
<field name="name">wt.location.log.search</field>
|
||||
<field name="model">wt.location.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Location Logs">
|
||||
<!-- Full-text search fields -->
|
||||
<field name="name" string="Name"/>
|
||||
<field name="address" string="Address"/>
|
||||
<field name="place_name" string="Place Name"/>
|
||||
<field name="category" string="Category"/>
|
||||
<field name="date" string="Date"/>
|
||||
|
||||
<!-- Travel Mode quick filters -->
|
||||
<separator/>
|
||||
<filter string="Driving" name="filter_driving" domain="[('travel_mode','=','driving')]"/>
|
||||
<filter string="Walking" name="filter_walking" domain="[('travel_mode','=','walking')]"/>
|
||||
<filter string="Cycling" name="filter_cycling" domain="[('travel_mode','=','cycling')]"/>
|
||||
<filter string="Flying" name="filter_flying" domain="[('travel_mode','=','flying')]"/>
|
||||
<filter string="Transit" name="filter_transit" domain="[('travel_mode','=','transit')]"/>
|
||||
<filter string="Unknown" name="filter_unknown" domain="[('travel_mode','=','unknown')]"/>
|
||||
|
||||
<!-- Category quick filters -->
|
||||
<separator/>
|
||||
<filter string="Home" name="filter_home" domain="[('category','in',['Home'])]"/>
|
||||
<filter string="Work" name="filter_work" domain="[('category','in',['Work'])]"/>
|
||||
|
||||
<!-- Group By -->
|
||||
<group expand="1" string="Group By">
|
||||
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
|
||||
<filter string="Category" name="group_category" context="{'group_by': 'category'}"/>
|
||||
<filter string="Travel Mode" name="group_travel_mode" context="{'group_by': 'travel_mode'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="wt_location_log_action" model="ir.actions.act_window">
|
||||
<field name="name">Location Logs</field>
|
||||
<field name="res_model">wt.location.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="wt_location_log_view_search"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
35
work_trace/views/wt_menu_views.xml
Normal file
35
work_trace/views/wt_menu_views.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Root Menu -->
|
||||
<menuitem id="wt_menu_root"
|
||||
name="WorkTrace"
|
||||
sequence="90"
|
||||
web_icon="work_trace,static/description/icon.png"/>
|
||||
|
||||
<!-- Location Sub-menu -->
|
||||
<menuitem id="wt_menu_location"
|
||||
name="Location"
|
||||
parent="wt_menu_root"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="wt_menu_location_logs"
|
||||
name="Location Logs"
|
||||
parent="wt_menu_location"
|
||||
action="wt_location_log_action"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="wt_menu_location_import"
|
||||
name="Import Google Timeline"
|
||||
parent="wt_menu_location"
|
||||
action="wt_import_timeline_wizard_action"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Date Range -->
|
||||
<menuitem id="wt_menu_date_range"
|
||||
name="Date Range"
|
||||
parent="wt_menu_root"
|
||||
action="wt_date_range_wizard_action"
|
||||
sequence="20"/>
|
||||
|
||||
</odoo>
|
||||
2
work_trace/wizards/__init__.py
Normal file
2
work_trace/wizards/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import wt_import_timeline_wizard
|
||||
from . import wt_date_range_wizard
|
||||
24
work_trace/wizards/wt_date_range_wizard.py
Normal file
24
work_trace/wizards/wt_date_range_wizard.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from odoo import models, fields, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class WtDateRangeWizard(models.TransientModel):
|
||||
_name = "wt.date.range.wizard"
|
||||
_description = "WorkTrace Date Range"
|
||||
|
||||
date_from = fields.Date(string="From", required=True)
|
||||
date_to = fields.Date(string="To", required=True)
|
||||
|
||||
def action_view(self):
|
||||
self.ensure_one()
|
||||
if self.date_from > self.date_to:
|
||||
raise UserError(_("Start date must be before end date."))
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Travel: %s to %s" % (self.date_from, self.date_to),
|
||||
"res_model": "wt.location.log",
|
||||
"view_mode": "list,form",
|
||||
"domain": [("date", ">=", self.date_from), ("date", "<=", self.date_to)],
|
||||
"context": {},
|
||||
"target": "current",
|
||||
}
|
||||
29
work_trace/wizards/wt_date_range_wizard_views.xml
Normal file
29
work_trace/wizards/wt_date_range_wizard_views.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="wt_date_range_wizard_view_form" model="ir.ui.view">
|
||||
<field name="name">wt.date.range.wizard.form</field>
|
||||
<field name="model">wt.date.range.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Date Range">
|
||||
<group>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_view" string="View Travel Records"
|
||||
type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="wt_date_range_wizard_action" model="ir.actions.act_window">
|
||||
<field name="name">Date Range</field>
|
||||
<field name="res_model">wt.date.range.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
449
work_trace/wizards/wt_import_timeline_wizard.py
Normal file
449
work_trace/wizards/wt_import_timeline_wizard.py
Normal file
@@ -0,0 +1,449 @@
|
||||
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
|
||||
from collections import Counter
|
||||
|
||||
VEHICLE_ACTIVITIES = {
|
||||
'IN_VEHICLE', 'IN_ROAD_VEHICLE', 'IN_RAIL_VEHICLE',
|
||||
'IN_TWO_WHEELER_VEHICLE', 'IN_PASSENGER_VEHICLE',
|
||||
}
|
||||
WALKING_ACTIVITIES = {'WALKING', 'ON_FOOT', 'RUNNING'}
|
||||
CYCLING_ACTIVITIES = {'ON_BICYCLE', 'CYCLING'}
|
||||
|
||||
SEMANTIC_TYPE_CATEGORY = {
|
||||
'HOME': 'Home',
|
||||
'INFERRED_HOME': 'Home',
|
||||
'WORK': 'Work',
|
||||
'INFERRED_WORK': 'Work',
|
||||
'SEARCHED_ADDRESS': 'Searched Address',
|
||||
'ALIASED_LOCATION': 'Saved Place',
|
||||
'UNKNOWN': 'Unknown',
|
||||
}
|
||||
|
||||
|
||||
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 _distance_meters(lat1, lon1, lat2, lon2):
|
||||
return _haversine_miles(lat1, lon1, lat2, lon2) * 1609.34
|
||||
|
||||
|
||||
def _activity_type_to_travel_mode(activity_type):
|
||||
if not activity_type:
|
||||
return None
|
||||
t = activity_type.upper()
|
||||
if 'UNKNOWN' in t:
|
||||
return None
|
||||
if t == 'IN_RAIL_VEHICLE':
|
||||
return 'transit'
|
||||
if t in VEHICLE_ACTIVITIES:
|
||||
return 'driving'
|
||||
if t in WALKING_ACTIVITIES:
|
||||
return 'walking'
|
||||
if t in CYCLING_ACTIVITIES:
|
||||
return 'cycling'
|
||||
return None
|
||||
|
||||
|
||||
def _speed_to_travel_mode(max_speed_mph):
|
||||
"""Infer travel mode from maximum speed observed."""
|
||||
if max_speed_mph > 150:
|
||||
return 'flying'
|
||||
if max_speed_mph > 15:
|
||||
return 'driving'
|
||||
if max_speed_mph > 3:
|
||||
return 'cycling'
|
||||
if max_speed_mph > 0:
|
||||
return 'walking'
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def _parse_latlng(latlng_str):
|
||||
nums = re.findall(r'-?\d+\.\d+', latlng_str)
|
||||
if len(nums) >= 2:
|
||||
return float(nums[0]), float(nums[1])
|
||||
return None, None
|
||||
|
||||
|
||||
def _parse_ts(ts_str):
|
||||
if not ts_str:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(ts_str).replace(tzinfo=None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _max_speed_from_path(timeline_path):
|
||||
"""Calculate the maximum speed (mph) from a timelinePath segment's points."""
|
||||
points = []
|
||||
for pt in timeline_path:
|
||||
latlng = pt.get('point', '')
|
||||
ts_str = pt.get('time', '')
|
||||
lat, lng = _parse_latlng(latlng)
|
||||
ts = _parse_ts(ts_str)
|
||||
if lat is not None and ts is not None:
|
||||
points.append((ts, lat, lng))
|
||||
|
||||
if len(points) < 2:
|
||||
return 0.0
|
||||
|
||||
points.sort(key=lambda x: x[0])
|
||||
max_speed = 0.0
|
||||
for i in range(1, len(points)):
|
||||
t0, lat0, lng0 = points[i - 1]
|
||||
t1, lat1, lng1 = points[i]
|
||||
dt_hours = (t1 - t0).total_seconds() / 3600.0
|
||||
if dt_hours <= 0:
|
||||
continue
|
||||
dist_miles = _haversine_miles(lat0, lng0, lat1, lng1)
|
||||
speed = dist_miles / dt_hours
|
||||
if speed > max_speed:
|
||||
max_speed = speed
|
||||
|
||||
return max_speed
|
||||
|
||||
|
||||
MIN_FLIGHT_DISTANCE_MILES = 50 # Shorter than this cannot be a flight
|
||||
MIN_FLIGHT_DURATION_HOURS = 0.5 # Less than 30 minutes cannot be a flight
|
||||
|
||||
def _speed_from_activity_segment(activity, seg_start_ts, seg_end_ts):
|
||||
"""Detect flights from an activity segment's start/end coords and timestamps.
|
||||
|
||||
Flights show up as UNKNOWN_ACTIVITY_TYPE with widely-separated start/end latLng.
|
||||
We require at least 50 miles distance to avoid mis-classifying GPS drift as flying
|
||||
(short jumps can imply absurdly high speeds due to imprecise timestamps).
|
||||
|
||||
Returns (distance_miles, speed_mph) or (0, 0) if not usable.
|
||||
"""
|
||||
start_latlng = activity.get('start', {}).get('latLng', '')
|
||||
end_latlng = activity.get('end', {}).get('latLng', '')
|
||||
if not start_latlng or not end_latlng:
|
||||
return 0.0, 0.0
|
||||
slat, slng = _parse_latlng(start_latlng)
|
||||
elat, elng = _parse_latlng(end_latlng)
|
||||
if slat is None or elat is None:
|
||||
return 0.0, 0.0
|
||||
if not seg_start_ts or not seg_end_ts:
|
||||
return 0.0, 0.0
|
||||
dt_hours = (seg_end_ts - seg_start_ts).total_seconds() / 3600.0
|
||||
if dt_hours <= 0:
|
||||
return 0.0, 0.0
|
||||
dist_miles = _haversine_miles(slat, slng, elat, elng)
|
||||
speed = dist_miles / dt_hours
|
||||
return dist_miles, speed, dt_hours
|
||||
|
||||
|
||||
class WtImportTimelineWizard(models.TransientModel):
|
||||
_name = 'wt.import.timeline.wizard'
|
||||
_description = 'Import Google Timeline'
|
||||
|
||||
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,
|
||||
)
|
||||
proximity_meters = fields.Integer(
|
||||
string='Location Proximity (meters)',
|
||||
default=200,
|
||||
help='For Timeline Edits.json only: GPS positions within this distance are grouped as one location'
|
||||
)
|
||||
geocode = fields.Boolean(
|
||||
string='Resolve Addresses via OpenStreetMap',
|
||||
default=False,
|
||||
help='Geocode addresses after import (slow: ~1 req/sec). Use the "Geocode Next 20" action after import instead.'
|
||||
)
|
||||
|
||||
def action_import(self):
|
||||
self.ensure_one()
|
||||
|
||||
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))
|
||||
|
||||
if 'semanticSegments' in data:
|
||||
stops = self._parse_semantic_timeline(data)
|
||||
elif 'timelineEdits' in data:
|
||||
stops = self._parse_raw_timeline(data, self.proximity_meters)
|
||||
else:
|
||||
raise UserError(_('Unrecognized format. Expected Timeline.json (semanticSegments) or Timeline Edits.json (timelineEdits).'))
|
||||
|
||||
if not stops:
|
||||
raise UserError(_('No location stops found in the file.'))
|
||||
|
||||
min_secs = self.min_stop_minutes * 60
|
||||
stops = [s for s in stops
|
||||
if s.get('arrived_at') and s.get('departed_at')
|
||||
and (s['departed_at'] - s['arrived_at']).total_seconds() >= min_secs]
|
||||
|
||||
if not stops:
|
||||
raise UserError(_('No stops found matching the minimum duration filter.'))
|
||||
|
||||
stops.sort(key=lambda s: s['arrived_at'])
|
||||
|
||||
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'])
|
||||
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:
|
||||
stop['distance_from_previous'] = 0.0
|
||||
stop['travel_time_from_previous'] = 0.0
|
||||
|
||||
# Post-processing: reclassify likely flights using stop-to-stop speed.
|
||||
# Many flights have no activity segment or timelinePath data — Google just
|
||||
# records a large gap between two distant visits. We catch those here.
|
||||
for stop in stops[1:]:
|
||||
dist = stop.get('distance_from_previous', 0.0)
|
||||
tt = stop.get('travel_time_from_previous', 0.0)
|
||||
if (dist >= MIN_FLIGHT_DISTANCE_MILES
|
||||
and tt >= MIN_FLIGHT_DURATION_HOURS
|
||||
and tt > 0):
|
||||
implied_speed = dist / tt
|
||||
if implied_speed > 150:
|
||||
stop['travel_mode'] = 'flying'
|
||||
|
||||
LocationLog = self.env['wt.location.log']
|
||||
existing = set(
|
||||
r.arrived_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
for r in LocationLog.search([])
|
||||
if r.arrived_at
|
||||
)
|
||||
|
||||
created_ids = []
|
||||
skipped = 0
|
||||
for stop in stops:
|
||||
arrived_str = stop['arrived_at'].strftime('%Y-%m-%d %H:%M:%S')
|
||||
if arrived_str in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
log = LocationLog.create({
|
||||
'date': stop['arrived_at'].date(),
|
||||
'arrived_at': stop['arrived_at'],
|
||||
'departed_at': stop['departed_at'],
|
||||
'latitude': stop.get('lat') or 0.0,
|
||||
'longitude': stop.get('lng') or 0.0,
|
||||
'place_name': stop.get('place_name', ''),
|
||||
'category': stop.get('category', ''),
|
||||
'travel_mode': stop.get('travel_mode', 'unknown'),
|
||||
'distance_from_previous': stop.get('distance_from_previous', 0.0),
|
||||
'travel_time_from_previous': stop.get('travel_time_from_previous', 0.0),
|
||||
'source': 'google_timeline',
|
||||
})
|
||||
created_ids.append(log.id)
|
||||
existing.add(arrived_str)
|
||||
|
||||
if not created_ids and skipped:
|
||||
raise UserError(_('All %d stops already exist. Nothing new to import.') % skipped)
|
||||
|
||||
created = LocationLog.browse(created_ids)
|
||||
if self.geocode and created:
|
||||
created.action_geocode()
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Imported %d new stops (%d skipped as duplicates)') % (len(created_ids), skipped),
|
||||
'res_model': 'wt.location.log',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', created_ids)],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _parse_semantic_timeline(self, data):
|
||||
"""Parse Timeline.json (semanticSegments format).
|
||||
|
||||
Travel mode priority:
|
||||
1. Explicit activity type (if not UNKNOWN)
|
||||
2. Speed inferred from activity segment start/end coords + timestamps
|
||||
(catches flights that have no timelinePath GPS trail)
|
||||
3. Max speed calculated from preceding timelinePath points
|
||||
4. 'unknown' as fallback
|
||||
"""
|
||||
stops = []
|
||||
segments = data.get('semanticSegments', [])
|
||||
|
||||
pending_mode = 'unknown'
|
||||
pending_path_points = []
|
||||
|
||||
for seg in segments:
|
||||
# --- timelinePath: collect GPS points for speed inference ---
|
||||
if 'timelinePath' in seg:
|
||||
pending_path_points.extend(seg['timelinePath'])
|
||||
continue
|
||||
|
||||
# --- activity segment ---
|
||||
if 'activity' in seg:
|
||||
activity = seg['activity']
|
||||
top = activity.get('topCandidate', {})
|
||||
mode = _activity_type_to_travel_mode(top.get('type', ''))
|
||||
if mode:
|
||||
# Explicit type wins (driving, walking, transit, cycling)
|
||||
pending_mode = mode
|
||||
else:
|
||||
# UNKNOWN type — only infer flying if distance is large enough
|
||||
# to rule out GPS drift (short jumps can look fast due to imprecise timestamps)
|
||||
seg_start = _parse_ts(seg.get('startTime'))
|
||||
seg_end = _parse_ts(seg.get('endTime'))
|
||||
dist, speed, dt_hours = _speed_from_activity_segment(activity, seg_start, seg_end)
|
||||
if (dist >= MIN_FLIGHT_DISTANCE_MILES
|
||||
and speed > 150
|
||||
and dt_hours >= MIN_FLIGHT_DURATION_HOURS):
|
||||
pending_mode = 'flying'
|
||||
continue
|
||||
|
||||
# --- visit segment: the stop we record ---
|
||||
if 'visit' in seg:
|
||||
start_ts = _parse_ts(seg.get('startTime'))
|
||||
end_ts = _parse_ts(seg.get('endTime'))
|
||||
if not start_ts or not end_ts:
|
||||
pending_mode = 'unknown'
|
||||
pending_path_points = []
|
||||
continue
|
||||
|
||||
candidate = seg['visit'].get('topCandidate', {})
|
||||
semantic_type = candidate.get('semanticType', 'UNKNOWN')
|
||||
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,
|
||||
semantic_type.replace('_', ' ').title() if semantic_type else 'Unknown'
|
||||
)
|
||||
|
||||
# Determine travel mode
|
||||
travel_mode = pending_mode
|
||||
if travel_mode == 'unknown' and pending_path_points:
|
||||
max_speed = _max_speed_from_path(pending_path_points)
|
||||
travel_mode = _speed_to_travel_mode(max_speed)
|
||||
|
||||
stops.append({
|
||||
'arrived_at': start_ts,
|
||||
'departed_at': end_ts,
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'place_name': '',
|
||||
'category': category,
|
||||
'travel_mode': travel_mode,
|
||||
})
|
||||
|
||||
pending_mode = 'unknown'
|
||||
pending_path_points = []
|
||||
continue
|
||||
|
||||
# timelineMemory or other segment types — ignore
|
||||
pass
|
||||
|
||||
return stops
|
||||
|
||||
def _parse_raw_timeline(self, data, proximity_meters=200):
|
||||
"""Parse Timeline Edits.json (raw GPS signal format from Google Takeout)."""
|
||||
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
|
||||
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})
|
||||
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 dominant_mode(start_ts, end_ts):
|
||||
window = [a for a in activities if start_ts <= a['ts'] <= end_ts]
|
||||
if not window:
|
||||
return 'unknown'
|
||||
counts = Counter(a['type'] for a in window)
|
||||
top_type = counts.most_common(1)[0][0]
|
||||
return _activity_type_to_travel_mode(top_type) or 'unknown'
|
||||
|
||||
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:
|
||||
current_cluster.append(pos)
|
||||
else:
|
||||
avg_lat = sum(p['lat'] for p in current_cluster) / len(current_cluster)
|
||||
avg_lng = sum(p['lng'] for p in current_cluster) / len(current_cluster)
|
||||
stops.append({
|
||||
'arrived_at': current_cluster[0]['ts'].replace(tzinfo=None),
|
||||
'departed_at': current_cluster[-1]['ts'].replace(tzinfo=None),
|
||||
'lat': avg_lat, 'lng': avg_lng,
|
||||
'travel_mode': dominant_mode(current_cluster[-1]['ts'], pos['ts']),
|
||||
'category': '', 'place_name': '',
|
||||
})
|
||||
current_cluster = [pos]
|
||||
|
||||
if current_cluster:
|
||||
avg_lat = sum(p['lat'] for p in current_cluster) / len(current_cluster)
|
||||
avg_lng = sum(p['lng'] for p in current_cluster) / len(current_cluster)
|
||||
stops.append({
|
||||
'arrived_at': current_cluster[0]['ts'].replace(tzinfo=None),
|
||||
'departed_at': current_cluster[-1]['ts'].replace(tzinfo=None),
|
||||
'lat': avg_lat, 'lng': avg_lng,
|
||||
'travel_mode': 'unknown', 'category': '', 'place_name': '',
|
||||
})
|
||||
|
||||
for i, stop in enumerate(stops):
|
||||
if stop['arrived_at'] == stop['departed_at'] and i + 1 < len(stops):
|
||||
gap = (stops[i + 1]['arrived_at'] - stop['arrived_at']).total_seconds()
|
||||
stop['departed_at'] = stop['arrived_at'] + timedelta(seconds=gap / 2)
|
||||
|
||||
return stops
|
||||
Reference in New Issue
Block a user