Compare commits

..

60 Commits

Author SHA1 Message Date
d1ea26dba1 fix: remove numbercall (Odoo 18 removed it), use cron_name
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
Mark stale issues and pull requests / stale (push) Has been cancelled
2026-03-14 08:28:14 +00:00
845365d5b0 feat: add access for date range wizard
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
2026-03-14 08:26:30 +00:00
a72d9f8c1b feat: bump version, add date range wizard to manifest
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
2026-03-14 08:26:14 +00:00
69194f81eb feat: add Date Range menu item
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
2026-03-14 08:26:13 +00:00
d826665de5 feat: register date range wizard
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
2026-03-14 08:25:34 +00:00
59eda16782 feat: add date range wizard view
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
2026-03-14 08:25:34 +00:00
71805569ab feat: add date range wizard (travel)
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
2026-03-14 08:25:13 +00:00
599ad94d2a feat: add date range wizard (travel)
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
2026-03-14 08:24:57 +00:00
f719a668d6 chore: add 18.0.1.0.2 migration package init
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
2026-03-14 06:26:09 +00:00
fb5d950733 chore: add migrations package init
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
2026-03-14 06:26:09 +00:00
2406fc9aa8 feat: add travel mode and category quick-filter buttons to search view
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
2026-03-14 05:28:14 +00:00
1ccc83a64e fix: detect flights via stop-to-stop speed (catches gaps with no activity segment)
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
2026-03-14 05:16:31 +00:00
67449c3047 fix: require >= 30 min duration for flying classification
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
2026-03-14 05:08:16 +00:00
fa177e0342 fix: require 50+ mile distance to classify activity segment as flying (avoids GPS drift false positives)
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
2026-03-14 05:05:12 +00:00
1b796b6ba4 fix: detect flights from activity segment coords; fix category for UNKNOWN/ALIASED_LOCATION
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
2026-03-14 04:03:23 +00:00
334a1a0f88 feat: use 'flying' mode for speeds >150mph, keep 'transit' for rail
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
2026-03-14 03:26:08 +00:00
cc8bd4ef00 feat: add 'flying' travel mode to selection
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
2026-03-14 03:25:24 +00:00
988d26df4e fix: infer travel_mode from timelinePath speed when activity type is UNKNOWN
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
2026-03-14 03:14:13 +00:00
7c65a34fce fix: extract travel_mode from activity segments preceding each visit in semanticSegments
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
2026-03-14 02:59:53 +00:00
aa714a8fd6 feat: load geocoding cron in manifest (v18.0.1.0.1)
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
2026-03-14 02:58:22 +00:00
2b678ff1be feat: add background geocoding cron job (20 records every 2 minutes)
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
2026-03-14 02:58:09 +00:00
82309f80fb feat: add Geocode Next 20 and Recompute Distances server actions to list view
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
2026-03-14 02:45:33 +00:00
5bcd8a5548 fix: batch geocoding (max 20/call) to prevent worker timeout, add action_geocode_all_pending
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
2026-03-14 02:44:41 +00:00
d242700b88 fix: default geocode to False, add help text about batch geocoding
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
2026-03-14 02:43:54 +00:00
34f6345486 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
2026-03-14 02:24:14 +00:00
a49fbbede7 Add server file path option 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
2026-03-14 02:23:24 +00:00
37b3540eb8 Major update: support Timeline.json semanticSegments with auto-detection
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
2026-03-14 02:11:21 +00:00
b57b2e75ab Update search view: dynamic field-based filters only, no hardcoded values
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
2026-03-14 00:31:28 +00:00
7351c558ce Update wizard: populate category from activity type, add cycling mode
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
2026-03-14 00:29:47 +00:00
0019199ed8 Update views: Category, Name, Address, Begin/End Time, Duration, Distance
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
2026-03-14 00:29:47 +00:00
80ae631d9c Update model: add category field matching extension fields
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
2026-03-14 00:29:47 +00:00
bed328abbc Update wizard view: add proximity_meters field
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
2026-03-14 00:02:55 +00:00
84657dde00 Rewrite parser: use proximity clustering instead of speed-based detection
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
2026-03-14 00:02:55 +00:00
aa11316dc6 Update wizard view: remove date filters, add info message
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
2026-03-13 23:49:50 +00:00
86e13bab51 Update wizard: remove date filters, add duplicate detection
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
2026-03-13 23:49:49 +00:00
5043ae7cc6 Fix: grant full access to timeline wizard for all users
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
2026-03-13 23:48:09 +00:00
ab2efe4e3a Fix: add access rule for timeline wizard
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
2026-03-13 23:40:19 +00:00
86cb6945c2 Fix: import wizards package in __init__.py
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
2026-03-13 23:38:51 +00:00
343bc95e6a Fix: remove wizard from security CSV, TransientModels dont need access rules
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
2026-03-13 23:34:34 +00:00
49b462628e Phase 1: Add wizard security rule
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
2026-03-13 23:27:41 +00:00
ab9ba5a15c Phase 1: Add import menu item
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
2026-03-13 23:27:40 +00:00
6410802bb5 Phase 1: Update manifest with wizard
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
2026-03-13 23:27:40 +00:00
4d2c9f3021 Phase 1: Update wizards __init__.py
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
2026-03-13 23:27:40 +00:00
26248113b7 Phase 1: Add timeline import wizard view
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
2026-03-13 23:27:39 +00:00
4a811f8666 Phase 1: Add Google Timeline import wizard
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
2026-03-13 23:27:08 +00:00
727e6ea096 Fix: remove fetchmail dependency (not yet installed)
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
2026-03-13 22:52:09 +00:00
0d2bf1177c Phase 1: Add security rules
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
2026-03-13 22:33:04 +00:00
83c6be73e4 Phase 1: Add menu views
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
2026-03-13 22:33:03 +00:00
a6473957f2 Phase 1: Add location log views
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
2026-03-13 22:33:03 +00:00
3d15443c46 Phase 1: Add WtLocationLog model
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
2026-03-13 22:32:23 +00:00
776b70bcce Phase 1: Add __manifest__.py
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
2026-03-13 22:31:56 +00:00
9b3abfa5df Phase 1: Add static directory
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
2026-03-13 22:31:28 +00:00
d61ca8bc2a Phase 1: Add data directory
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
2026-03-13 22:31:27 +00:00
738c3c4f8f Phase 1: Add security directory
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
2026-03-13 22:31:27 +00:00
555e01380b Phase 1: Add views directory
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
2026-03-13 22:31:27 +00:00
63d9ba40ed Phase 1: Add wizards __init__.py
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
2026-03-13 22:31:26 +00:00
d3722033be Phase 1: Add models __init__.py
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
2026-03-13 22:31:26 +00:00
7e7d2831cf Phase 1: Add module __init__.py
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
2026-03-13 22:31:26 +00:00
tocmo0nlord
400df898e3 Remove map view - data only for location tracking
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
2026-03-13 22:20:37 +00:00
tocmo0nlord
44be28c087 Add WorkTrace module specification
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
2026-03-13 21:49:50 +00:00
20 changed files with 1302 additions and 0 deletions

337
work_trace/SPEC.md Normal file
View 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
View File

@@ -0,0 +1,2 @@
from . import models
from . import wizards

View 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
View File

View 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>

View File

View File

@@ -0,0 +1 @@
from . import wt_location_log

View 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

View File

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_wt_location_log wt.location.log model_wt_location_log 1 1 1 1
3 access_wt_import_timeline_wizard access_wt_import_timeline_wizard model_wt_import_timeline_wizard base.group_user 1 1 1 1
4 access_wt_date_range_wizard access_wt_date_range_wizard model_wt_date_range_wizard base.group_user 1 1 1 1

View File

View File

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
from . import wt_import_timeline_wizard
from . import wt_date_range_wizard

View 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",
}

View 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>

View 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