# fusion_schedule — Claude Code Instructions > Module-level guide. The repo-wide Odoo 19 rules in `K:\Github\Odoo-Modules\CLAUDE.md` > (and the global `K:\Github\CLAUDE.md`) **still apply** — this file only adds what is > specific to `fusion_schedule`. Read both. > > **Companion docs:** [`CODE_MAP.md`](CODE_MAP.md) is the precise symbol-level > "where-is-what" index (every field/method/route/JS fn/template with line numbers) — use it > to locate code; use this file for guidance. Open audit findings are tracked in Supabase > `fusionapps.issues` under project **Fusion Schedule** > (`576de219-57e6-4596-8c8c-0c093e4cb54a`) and summarised in §16 below. > > **Provenance:** this module was originally designed & coded with **Cursor using Claude 4.5 > Opus** (AI-generated), then audited by Claude Code. That shows in the failure profile: the > Odoo-19 *syntax/idioms* are clean (no deprecated APIs), but the bugs cluster in semantic areas > that need domain reasoning or a running install to catch — unscoped ORM queries (cross-user > event merging), timezone handling, copy-paste-drifted duplicates (authenticated vs public > booking), swallowed exceptions, and untested public/render paths. When extending it, **assume > plausible-but-unverified until tested on Enterprise.** --- ## 1. What this module is **Fusion Schedule** (`fusion_schedule`, `__manifest__.py` version **19.0.2.1.0**, author "Fusion Claims", LGPL-3) is a **multi-account calendar synchronisation hub + portal booking system** for staff (authorizers / sales reps / technicians) in the Fusion Claims product family. Three product surfaces, one engine: 1. **Multi-calendar sync** — a staff user connects any number of **Google** and **Microsoft Outlook** calendars. A 5-minute cron pulls external events into Odoo `calendar.event` and pushes Odoo-native events out, so the user has one merged calendar and is "busy on one → blocked on all". 2. **Portal "My Schedule"** (`/my/schedule`) — a portal dashboard: today's + upcoming appointments, connected-account management, schedule preferences (work hours / break / travel buffer / base address), a booking form with a week-calendar preview, **AI slot suggestions** and **AI day-route optimization**, and travel-time blocking. 3. **Public booking links** (`/schedule/`) — each user gets a shareable slug; external visitors (no login) can self-book into the user's free slots and later cancel/reschedule via a per-event **manage token** (`/schedule/manage/`). > ⚠️ This is the active **Outlook ↔ Odoo sync** for this deployment — **not** Odoo's native > `microsoft_calendar`/`google_calendar` sync. The backend calendar UI patch (see §11) > deliberately **hides** the native sync buttons and substitutes Fusion Schedule's own. It was originally built in **Cursor** (note the leftover `graphify-out/` artifact — a Cursor code-graph dump; safe to ignore/delete, not loaded by Odoo). Development now happens in Claude Code. --- ## 2. Enterprise-only — you cannot install this on local Community The manifest depends on **`appointment`** (Odoo **Enterprise**), plus `google_account` and `microsoft_account`. Therefore — like `fusion_portal` and `fusion_repairs` — **it cannot be installed or tested on local `odoo-modsdev` (Community).** The old `-d fusion-dev -u ` recipe does **not** work here. Test on an Enterprise environment (a Westin clone is the natural choice since `fusion_portal` already runs there — see the *Westin Prod* section of the repo `CLAUDE.md`). There are currently **no automated tests** in this module (`tests/` does not exist). --- ## 3. Dependency map ### 3.1 Hard dependencies (`__manifest__.py` → `depends`) ``` base · portal · website · calendar · appointment · google_account · microsoft_account · fusion_portal ``` - `appointment` — Enterprise. Uses `appointment.type`, `appointment.invite`, and `appointment_type._prepare_calendar_event_values(...)` to build booking events. - `calendar` — the core model everything revolves around (`calendar.event` is inherited). - `google_account` / `microsoft_account` — base OAuth plumbing. **Note:** the module rolls its *own* OAuth flow (it does not reuse `google_calendar`/`microsoft_calendar` sync). It only borrows their stored client-id ICP params as a *fallback* (see §10). - `fusion_portal` — the **only `fusion_*` hard dependency**. This is what transitively pulls in the whole claims stack: `fusion_portal → fusion_claims` (+ `fusion_tasks`, `fusion_loaners_management`, `knowledge`). So **`fusion_claims` is a transitive dependency**, always present at runtime. ### 3.2 Soft dependencies (used via `try/except`, NOT in `depends`) - **`fusion_api`** (`fusion.api.service`) — preferred broker for the Google Maps key and OpenAI calls. Not declared in `depends`; every call is wrapped in `try/except` and falls back to `fusion_claims.*` ICP params, then degrades gracefully. The module still runs if `fusion_api` is absent. ### 3.3 Reverse dependencies - **Nothing depends on `fusion_schedule`.** It is a leaf/top module. The only mention elsewhere is `fusion_repairs/__manifest__.py` which lists "fusion_schedule slots" as a *deferred / future* integration — not a real dependency today. ``` ┌─────────────────┐ │ fusion_schedule │ (leaf — nothing depends on it) └────────┬────────┘ depends │ soft (try/except, NOT in manifest) ┌─────────────────┼──────────────────────────┐ ▼ ▼ ▼ fusion_portal appointment (EE) fusion_api ── fusion.api.service │ google_account / microsoft_account (Maps key + OpenAI broker) ▼ fusion_claims ── owns the `fusion_claims.*` ICP params reused as fallbacks │ (+ fusion_tasks, fusion_loaners_management, knowledge) ``` --- ## 4. ⭐ Relationship with `fusion_claims` (read this — it's the whole point of the coupling) `fusion_schedule` **does not modify any `fusion_claims` model or view.** The coupling is indirect and entirely through shared infrastructure. Five concrete links: ### 4.1 Transitive dependency (stack position) `fusion_schedule` sits **on top of** the claims stack via `fusion_portal → fusion_claims`. It assumes the claims/portal data model and the authorizer/sales-rep portal already exist. ### 4.2 Config-parameter namespace reuse (the main runtime link) The portal pages **borrow `fusion_claims`-owned `ir.config_parameter` values** so the schedule UI matches the claims portal branding and shares the same API keys. These params are **defined in `fusion_claims/models/res_config_settings.py`**, *not* here: | ICP key (owned by fusion_claims) | Used in fusion_schedule for | Where | |---|---|---| | `fusion_claims.portal_gradient_start` / `_mid` / `_end` | portal header gradient (brand colour) | `PortalSchedule._get_schedule_values()` | | `fusion_claims.google_maps_api_key` | Maps/Places/Distance-Matrix key **fallback** | `_get_maps_api_key()` | | `fusion_claims.ai_api_key` | OpenAI key **fallback** (direct HTTP) | `_call_ai()` | > If you rename/remove these in `fusion_claims`, the schedule portal silently loses its > gradient / maps / AI. They are read with defaults, so it won't crash — it just degrades. ### 4.3 The `fusion.api.service` broker (preferred path, fusion_claims-family convention) `_get_maps_api_key()` and `_call_ai()` first try `request.env['fusion.api.service']` (from **`fusion_api`**) — the same metered, budget-/rate-limited broker the rest of the Fusion family uses — with `consumer='fusion_schedule'`. Only if that raises do they fall back to the `fusion_claims.*` ICP params above. So the order is: **`fusion_api` broker → `fusion_claims` ICP param → graceful no-op.** > Two non-obvious facts (detail in [`CODE_MAP.md`](CODE_MAP.md) §9): (1) `get_api_key` returns > the `group_admin`-gated `key.api_key` on a **non-sudo** recordset, so from a portal/public > request it likely raises `AccessError` and the **ICP fallback fires every time** — for portal > callers `fusion_claims.google_maps_api_key` is effectively the real source, not the broker. > (2) That maps-key param is actually **owned by `fusion_tasks`** (`res_config_settings.py:12`), > not `fusion_claims`, despite the `fusion_claims.*` prefix — grepping `fusion_claims/` for it > finds nothing. ### 4.4 Portal tile injection (into fusion_portal, which is built on fusion_claims) `views/portal_schedule_tile.xml` (`portal_my_home_schedule`, priority 45) inherits **`fusion_portal.portal_my_home_authorizer`** (which itself inherits `portal.portal_my_home`, priority 40) and `xpath`s a "My Schedule" card into the authorizer/sales-rep portal home grid. It reuses fusion_portal's `fc_gradient` template var. - **`fc_gradient` origin:** set in `fusion_portal/views/portal_templates.xml` as `portal_gradient or `, where `portal_gradient` is computed by fusion_portal's home controller from the same `fusion_claims.portal_gradient_*` params (§4.2). The tile falls back to the literal default if `fc_gradient` is unset. - **⚠ Fragile xpath:** the tile anchors on `//a[@href='/my/funding-claims']/ancestor::div[hasclass('row') and hasclass('g-3') and hasclass('mb-4')]`. If fusion_portal renames the funding-claims route, removes that card, or restructures the home grid's classes, the tile **silently disappears** (or the view fails to load on `-u`). Re-check this xpath whenever fusion_portal's home template changes. ### 4.5 The `tz` cookie is populated by fusion_portal Fusion Schedule's timezone resolution (`_resolve_timezone`) reads a browser **`tz`** cookie (IANA name). That cookie is set by **`fusion_portal/static/src/js/timezone_detect.js`** (`tz=;path=/;max-age=1yr;SameSite=Lax`) — and, redundantly, by Fusion Schedule's own booking JS (`setTzCookie` IIFE). So on portal pages the correct timezone flows in **from fusion_portal**; without that cookie (or a `user.tz`), times fall back to the company calendar tz, then UTC. ### 4.5 Parallel/overlapping scheduling — they share the `calendar.event` table `fusion_claims` already has its **own, simpler** scheduling: - `fusion_claims.schedule.assessment.wizard` (`wizard/schedule_assessment_wizard.py`) — a *backend* wizard that creates a plain `calendar.event` for an ADP assessment from a `sale.order` (optional 1-day email alarm). No sync, no portal, no travel logic. - `technician_task` routing — push notifications + travel time using the **same** `fusion_claims.google_maps_api_key`. `fusion_schedule` is the **newer, richer, portal-facing + multi-calendar layer**. Both write to `calendar.event`, so they **interplay**: an assessment event created by the fusion_claims wizard for a user who has connected calendars will be picked up by Fusion Schedule's **cross-calendar push** (it's an unlinked `calendar.event` on the user's partner) and mirrored to that user's external calendar, and it appears in `/my/schedule`. They are **complementary, not isolated** — keep that shared table in mind when changing either side. --- ## 5. Data model All custom fields use the `x_fc_*` prefix (repo convention). Models load in this order (`models/__init__.py`): `fusion_calendar_account → fusion_calendar_event_link → calendar_event → res_users → res_config_settings`. ### 5.1 `fusion.calendar.account` — the OAuth account + sync engine *(god object, ~35 edges)* `models/fusion_calendar_account.py`. One row per connected external calendar. | Field | Notes | |---|---| | `x_fc_user_id` (m2o res.users, required, cascade) | owner | | `x_fc_provider` (sel: google/microsoft, required) | | | `x_fc_email` / `x_fc_name` (compute, stored) | label = "Google — a@b.com" | | `x_fc_active` (bool) | | | `x_fc_rtoken` / `x_fc_token` / `x_fc_token_validity` | **`groups='base.group_system'`** — OAuth secrets, admin-only | | `x_fc_sync_token` | provider delta/sync token (`group_system`). Clear it to force a fresh full sync | | `x_fc_calendar_id` (default `'primary'`) | | | `x_fc_last_sync`, `x_fc_sync_status` (active/error/paused), `x_fc_error_message` | | | `x_fc_link_ids` (o2m → event link) | | This file is the engine. Key method groups (all on the account record): - **Credential resolution** `_get_google_client_id/_secret`, `_get_microsoft_*` — dedicated `fusion_schedule_*` ICP param → native `google_calendar_client_id` / `microsoft_calendar_client_id`. - **Token mgmt** `_get_valid_token` (1-min skew buffer), `_refresh_token` → `_refresh_google_token` / `_refresh_microsoft_token` (MS may rotate the refresh token — it's re-saved). On HTTP 400/401 the account is marked `error` and tokens cleared. - **Code exchange** `_exchange_google_code` / `_exchange_microsoft_code` (called from the controller callback). `_fetch_google_email` / `_fetch_microsoft_email`. - **Pull (external → Odoo)** `_sync_pull` → `_sync_pull_google` / `_sync_pull_microsoft`, with `_google_request_with_retry` / `_microsoft_request_with_retry` (429/503 + connection retry, capped). Google initial window **now-14d … now+30d**; subsequent syncs use the sync token (HTTP 410 → drop token, full resync). MS uses Graph `calendarView/delta`; delta token expiry (`fullSyncRequired`/`SyncStateNotFound`) → full resync. MS page cap: 2000 events initial / 5000 incremental. - **Event mapping** `_google_event_to_odoo_vals` / `_microsoft_event_to_odoo_vals` and the reverse `_odoo_event_to_google` / `_odoo_event_to_microsoft`. - **Upsert/dedup** `_process_google_event` / `_process_microsoft_event`, `_find_existing_event` (matches name+start+stop, **includes archived** to reuse), and `_upsert_event_link`. - **Push (Odoo → external)** `_sync_push_event` (+ insert/patch/delete per provider). - **Cross-calendar busy block** `_cross_calendar_push` (see §6.3). - **Backend RPC** `get_user_accounts_status()`, `sync_current_user()` (called from the calendar UI patch). - **Cron** `_cron_sync_all_accounts()`. - **Teardown** `action_disconnect()` — deletes pushed external events, unlinks rows, pauses. ### 5.2 `fusion.calendar.event.link` — Odoo-event ↔ external-event join `models/fusion_calendar_event_link.py`. One row per (Odoo event, account). - `x_fc_event_id` (m2o calendar.event, cascade), `x_fc_account_id` (m2o account, cascade), `x_fc_external_id` (required), `x_fc_universal_id` (iCalUID — used for cross-provider dedup), `x_fc_last_synced`, `x_fc_sync_direction` (pull/push/both). - **Constraint:** `models.Constraint('UNIQUE(x_fc_account_id, x_fc_external_id)')` — an external event links once per account. (Odoo-19 declarative constraint, per repo rule #9.) ### 5.3 `calendar.event` (inherited) `models/calendar_event.py`. Adds: - `x_fc_source_account_id` (m2o account) — set when an event was *pulled* from external; used for colour-coding the source in the portal. - `x_fc_is_external` (compute, **stored** from source account). - `x_fc_link_ids` (o2m → link). - `x_fc_manage_token` (indexed, `copy=False`) — 32-hex public manage token. - `x_fc_client_email` / `x_fc_client_phone`. - `x_fc_address_lat` / `x_fc_address_lng` (Float, digits 10,7) — for travel-time calc. - `x_fc_travel_minutes_before` (int) and `x_fc_is_travel_block` (bool) — travel placeholder events generated after booking. - **`write()` / `unlink()` overrides** push updates/deletions to all linked external calendars — **unless** `_skip_fc_sync()` is true (context has `no_calendar_sync` or `dont_notify`). `write()` only pushes when a sync-relevant field changed. ### 5.4 `res.users` (inherited) `models/res_users.py`. Adds per-staff scheduling config: - `x_fc_calendar_account_ids` (o2m), `x_fc_schedule_slug` (**`UNIQUE` constraint**), `x_fc_booking_enabled` (default False). - Work prefs: `x_fc_work_start` (9.0), `x_fc_work_end` (17.0), `x_fc_break_start` (12.0), `x_fc_break_duration` (0.5h), `x_fc_travel_buffer` (30 min), `x_fc_home_address` + `x_fc_home_lat`/`x_fc_home_lng`. - **`create()` override** auto-generates a slug from the name + 4-hex suffix (`_generate_schedule_slug`). Every user (including pre-existing ones created elsewhere) gets a unique public slug. ### 5.5 `res.config.settings` (inherited) `models/res_config_settings.py`. See §12. --- ## 6. The sync engine — how events flow ### 6.1 Pull (external → Odoo), per account 1. `_get_valid_token()` (refresh if needed). 2. Fetch pages (sync-token delta when available, else the ±window). 3. For each event: cancelled/removed → archive local + unlink the link row; otherwise **upsert** with a 3-tier dedup ladder: - existing link for `(account, external_id)` → update in place; - else existing link by **iCalUID** (cross-provider/same-event) → relink; - else `_find_existing_event` by name+start+stop (incl. archived) → reuse + relink; - else **create** a new `calendar.event` (owner partner attached) + new link. 4. Persist `x_fc_sync_token`, `x_fc_last_sync`, status. ### 6.2 Push (Odoo → external), per event `calendar.event.write()` triggers `_sync_push_event` on each linked active account (insert if no link, patch if linked). New links are tagged `direction='push'`. ### 6.3 Cross-calendar busy-blocking (`_cross_calendar_push`) Runs in the cron **only for users with ≥2 active accounts**. It finds the user's **Odoo-native** events (those with **no** existing link) in the window now-1d … now+90d and pushes them to the **first active account only** (lowest id). Pushing to a single calendar + only un-linked events together prevent the **pull → push → pull feedback loop** and cross-calendar duplicates. *This is the "busy on one, blocked on all" mechanism.* ### 6.4 Cron `data/ir_cron_data.xml` → `ir_cron_fusion_calendar_sync`, every **5 minutes**, runs as `base.user_root`, code `model._cron_sync_all_accounts()`. Never-synced accounts are processed first. Per-account isolation uses `self.env.cr.commit()` / `rollback()` so one bad account doesn't poison the batch (see §13 footgun about tests). --- ## 7. OAuth connect/callback flow `/my/schedule/connect/google` and `/connect/microsoft` build the auth URL (scopes: Google `calendar` + `userinfo.email`, offline + consent; Microsoft `offline_access openid Calendars.ReadWrite User.Read`), stash a CSRF token in `request.session['fc_oauth_csrf']`, and encode `{provider, csrf}` into `state`. Redirect URI is always `/my/schedule/oauth/callback`. `/my/schedule/oauth/callback` validates `state` + CSRF, exchanges the code, fetches the account email, then **find-or-creates** a `fusion.calendar.account` (re-activating a matching existing one). Requires a **refresh token** — if the provider didn't return one, it errors asking the user to grant offline access. There's a resilience fallback: `_find_recently_connected_account` (created in the last 10 min) so a refreshed/timed-out callback still reports success instead of erroring. --- ## 8. Travel time + AI scheduling - **Travel time** `_get_travel_time(lat,lng→lat,lng)` — Google **Distance Matrix** (driving, avoid tolls, depart now), returns minutes or 0 on any failure. `_geocode_address` uses the Geocoding API (region `ca`). - **Travel blocks** `_create_travel_blocks(event, staff_user)` — after a booking, looks at the prev/next located appointments that day and inserts `Travel to …` placeholder events (`x_fc_is_travel_block=True`, `show_as=busy`) sized to `max(distance-matrix, travel_buffer)`. - **AI slot suggest** `/my/schedule/ai/suggest` — builds a schedule context, asks OpenAI (`gpt-4o-mini`) to pick **exactly 3** times **from the provided free-slot list only** (strict prompt + post-filter against the real slots; never invents times). Used by the booking form. - **AI day optimize** `/my/schedule/ai/optimize` — needs ≥2 located appointments; builds a travel matrix and asks OpenAI for an optimal visiting order + suggested times + savings. - Both AI calls route through `_call_ai()` (`fusion.api.service.call_openai` → `fusion_claims.ai_api_key` direct-HTTP fallback). Failures degrade to "AI unavailable". --- ## 9. Routes (controllers/portal_schedule.py — `PortalSchedule(CustomerPortal)`) | Method | Route | Auth | Renders / returns | |---|---|---|---| | http | `/my/schedule` | user | `portal_schedule_page` | | jsonrpc | `/my/schedule/preferences` | user | save work/break/travel/home prefs (geocodes address) | | http | `/my/schedule/book` | user | `portal_schedule_book` | | jsonrpc | `/my/schedule/available-slots` | user | free slots for a date | | jsonrpc | `/my/schedule/week-events` | user | Mon–Sun events for the week strip | | http POST | `/my/schedule/book/submit` | user | create booking (+ confirmation email + travel blocks) | | jsonrpc | `/my/schedule/event/cancel` | user | delete own event | | jsonrpc | `/my/schedule/event/reschedule` | user | move own event | | jsonrpc | `/my/schedule/ai/suggest` | user | 3 AI slot picks | | jsonrpc | `/my/schedule/ai/optimize` | user | AI day route | | http | `/my/schedule/connect/google` · `/connect/microsoft` | user | start OAuth | | http | `/my/schedule/oauth/callback` | user | finish OAuth | | jsonrpc | `/my/schedule/disconnect` | user | `action_disconnect` | | jsonrpc | `/my/schedule/sync-now` | user | `_sync_pull` one account | | jsonrpc | `/my/schedule/toggle-booking` | user | enable/disable public page | | http | `/schedule/` | **public** | `public_booking_page` | | jsonrpc | `/schedule//available-slots` | **public** (csrf=False) | slots | | http POST | `/schedule//book` | **public** (csrf) | public booking | | http | `/schedule/manage/` | **public** | `public_manage_page` | | http POST | `/schedule/manage//cancel` · `/reschedule` | **public** (csrf) | self-service | | jsonrpc | `/schedule/manage//available-slots` | **public** (csrf=False) | slots | Backend (ORM, not HTTP), called from the calendar UI patch: `fusion.calendar.account.get_user_accounts_status()` and `.sync_current_user()`. **Slot generation** (`_generate_available_slots`) is the shared core for *all* slot endpoints: honours the staff user's work hours / break / travel-buffer, intersects with appointment-type recurring slots, removes past times, and rejects slots that overlap any existing event **plus the travel buffer** after it. **Timezone resolution** (`_resolve_timezone`): `user.tz` → `tz` cookie (set by the frontend JS / fusion_portal, §4.5) → `company.resource_calendar_id.tz` → UTC. ### 9.1 Authenticated portal vs public booking are TWO separate implementations This is the single most important structural fact the templates reveal — the two booking flows do **not** share code and behave differently: | | Authenticated `/my/schedule/book` | Public `/schedule/` | |---|---|---| | Layout | `portal.portal_layout` (portal chrome + breadcrumbs) | `website.layout` (public site chrome) | | Slot/booking JS | the **registered asset files** (`portal_schedule_booking.js`, `portal_schedule_accounts.js`) | **inline `