800 lines
50 KiB
Markdown
800 lines
50 KiB
Markdown
# 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/<slug>`) — 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/<token>`).
|
||
|
||
> ⚠️ 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 <module>` 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 <default green/blue>`, 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=<IANA>;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
|
||
`<web.base.url>/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/<slug>` | **public** | `public_booking_page` |
|
||
| jsonrpc | `/schedule/<slug>/available-slots` | **public** (csrf=False) | slots |
|
||
| http POST | `/schedule/<slug>/book` | **public** (csrf) | public booking |
|
||
| http | `/schedule/manage/<token>` | **public** | `public_manage_page` |
|
||
| http POST | `/schedule/manage/<token>/cancel` · `/reschedule` | **public** (csrf) | self-service |
|
||
| jsonrpc | `/schedule/manage/<token>/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/<slug>` |
|
||
|---|---|---|
|
||
| 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 `<script>`** embedded in `public_booking.xml` (a *second copy* of the slot-render + Places-autocomplete logic) |
|
||
| Brand gradient | `portal_gradient` from `fusion_claims.*` params | **hardcoded** `linear-gradient(135deg,#5ba848,#3a8fb7)` — ignores the brand params |
|
||
| Event creation | `appointment_type._prepare_calendar_event_values(...)` → a real **appointment** with booking lines/capacity | a **raw `calendar.event`** dict (no appointment lines, no capacity) |
|
||
| Slot re-validation on submit | **yes** — re-runs `_generate_available_slots` and rejects stale slots | **no** — trusts the posted `slot_datetime` (double-book risk) |
|
||
| Week-calendar preview + AI suggest/optimize | yes | no |
|
||
|
||
So "fix the booking form" almost always means **edit two places**. Changing slot logic in
|
||
the Python `_generate_available_slots` covers both (it's shared server-side), but any
|
||
client-side change to slot rendering, autocomplete, or validation must be mirrored between
|
||
`portal_schedule_booking.js` and the inline script in `public_booking.xml`.
|
||
|
||
### 9.2 Two share links, one of them dead
|
||
- `schedule_page` computes `share_url = appointment.invite.book_url` (native appointment
|
||
share, looked up by `staff_user_ids`) **and** `public_booking_url = <base>/schedule/<slug>`.
|
||
Only **`public_booking_url`** is actually rendered (the "Share Booking Link" card/button).
|
||
`share_url` is passed to the template but **never used** — and the only seeded
|
||
`appointment.invite` (`default_appointment_invite`) has empty `appointment_type_ids`/no
|
||
staff, so it would be blank anyway. The slug link is the real share mechanism.
|
||
- There is **no `_prepare_home_portal_values` override**, so `/my/schedule` has **no portal
|
||
home counter** and no portal breadcrumb registration — the injected tile (§4.4) is the
|
||
only discoverable entry point besides the calendar-view cog button (§11).
|
||
|
||
---
|
||
|
||
## 10. ICP parameters (full list)
|
||
|
||
**Owned by this module:**
|
||
- OAuth creds: `fusion_schedule_google_client_id`, `fusion_schedule_google_client_secret`,
|
||
`fusion_schedule_microsoft_client_id`, `fusion_schedule_microsoft_client_secret`
|
||
- Sync: `fusion_schedule_sync_interval` (minutes; **note:** the cron interval is set in XML,
|
||
this param is currently informational — changing it does not re-write the cron)
|
||
- Defaults: `fusion_schedule.default_work_start` / `_work_end` / `_break_start` /
|
||
`_break_duration` / `_travel_buffer`
|
||
|
||
**Fallbacks read from elsewhere (not owned here):**
|
||
- Native Odoo: `google_calendar_client_id`, `google_calendar_client_secret`,
|
||
`microsoft_calendar_client_id`, `microsoft_calendar_client_secret`, `web.base.url`
|
||
- **fusion_claims namespace:** `fusion_claims.portal_gradient_start/_mid/_end`,
|
||
`fusion_claims.google_maps_api_key`, `fusion_claims.ai_api_key` (see §4.2)
|
||
|
||
---
|
||
|
||
## 11. Frontend / assets
|
||
|
||
Registered in `__manifest__.py` `assets`:
|
||
|
||
**`web.assets_backend`** — patches the native calendar:
|
||
- `static/src/views/fusion_calendar_controller.js` — `patch(AttendeeCalendarController…)`:
|
||
loads connected accounts (`get_user_accounts_status`) and adds a "Sync now"
|
||
(`sync_current_user`) action.
|
||
- `static/src/views/fusion_calendar_controller.xml` — t-inherits
|
||
`calendar.AttendeeCalendarController`, **hides** `#header_synchronization_settings` (the
|
||
native Google/Outlook sync UI, kept in DOM so other xpaths survive) and injects Fusion's
|
||
account chips + sync button + a cog link to `/my/schedule`.
|
||
|
||
**`web.assets_frontend`** — portal pages:
|
||
- `static/src/css/portal_schedule.css`
|
||
- `static/src/js/portal_schedule_booking.js` — booking form: sets the `tz` cookie, week
|
||
calendar strip, slot fetch + morning/afternoon grouping, AI suggestions, **Google Places
|
||
address autocomplete** (`country: 'ca'`, writes hidden lat/lng), submit guards.
|
||
- `static/src/js/portal_schedule_accounts.js` — the `/my/schedule` dashboard: reusable
|
||
`fusionConfirm` modal + `fusionToast`, disconnect/sync-now, share-link (Web Share /
|
||
clipboard), save-preferences, cancel/reschedule modals, AI "optimize my day" modal.
|
||
|
||
These are **plain IIFE scripts** (not Odoo `Interaction` classes) that bind to **DOM element
|
||
IDs** in the QWeb templates. If you rename an element id in the templates you must update the
|
||
JS, and vice-versa. Key ids the JS expects: `bookingDate`, `appointmentTypeSelect`,
|
||
`slotsContainer/slotsGrid/slotsLoading/noSlots`, `slotDatetime`, `slotDuration`,
|
||
`weekCalendar*`, `aiSuggest*`, `clientStreet/clientCity/clientProvince/clientPostal/clientLat/clientLng`,
|
||
`rescheduleModal` (+ children), `optimizeModal` (+ children), `schedulePrefsForm`,
|
||
`fusionConfirmModal`.
|
||
|
||
**Templates** (QWeb):
|
||
- `views/portal_schedule.xml` → `portal_schedule_page`, `portal_schedule_book`
|
||
(both `portal.portal_layout`).
|
||
- `views/public_booking.xml` → `public_booking_page`, `public_manage_page`
|
||
(both `website.layout`; **carry their own inline `<script>`** — see §9.1).
|
||
- `views/portal_schedule_tile.xml` → `portal_my_home_schedule` (the fusion_portal tile).
|
||
|
||
Frontend wiring notes:
|
||
- **Google Maps loader handshake.** The booking templates inject the Maps Places script with
|
||
`&callback=initScheduleAddressAutocomplete` (public: `initPublicAddressAutocomplete`). Because
|
||
the async script can land before *or* after the IIFE in `portal_schedule_booking.js`, they
|
||
coordinate via `window._googleMapsReady` / `window._scheduleAutocompleteInit`. Maps only
|
||
loads when a `google_maps_api_key` resolved (§4.2/§4.3) — no key ⇒ no autocomplete, fields
|
||
still work manually.
|
||
- **Dead toast markup.** `portal_schedule.xml` ships a Bootstrap `#fusionToast` /
|
||
`#fusionToastMessage` element, but `portal_schedule_accounts.js` defines its own
|
||
`fusionToast()` that builds a fresh `#fusionToastLive` node and **ignores** the template
|
||
one. Don't wire new code to `#fusionToast`; call the JS `fusionToast(msg, type)` helper.
|
||
- **CSS** (`portal_schedule.css`) is tiny: collapse-chevron rotation, a `.min-width-0`
|
||
truncation helper, and mobile sizing for slot buttons / tables / modals. No theming —
|
||
colours come from the inline `portal_gradient` styles and Bootstrap utility classes.
|
||
|
||
---
|
||
|
||
## 12. Settings UI
|
||
|
||
`views/res_config_settings_views.xml` adds a **"Fusion Schedule"** app block to
|
||
Settings (`base.res_config_settings_view_form`, priority 90) with: Sync Interval, Google
|
||
OAuth creds (+ "using Odoo default" hint via `x_fc_google_has_fallback`), Microsoft OAuth
|
||
creds (+ fallback hint), and Schedule Defaults (work hours / break / travel buffer, all
|
||
`float_time` widgets). The compute fields `x_fc_*_has_fallback` light up when no dedicated
|
||
key is set but a native `*_calendar_client_id` exists.
|
||
|
||
Backend list/form for accounts: `views/fusion_calendar_account_views.xml` →
|
||
action + menu **Settings → Technical → Calendar Accounts** (`base.menu_custom`).
|
||
|
||
---
|
||
|
||
## 13. Security
|
||
|
||
`security/security.xml` — two record rules (both additive on `base.group_user`):
|
||
- users see only their own `fusion.calendar.account` (`x_fc_user_id = user.id`);
|
||
- users see only event links for their own accounts.
|
||
|
||
`security/ir.model.access.csv` — account: full CRUD for `group_user`, none for
|
||
`group_public`; event link: CRU for `group_user`, full for `group_system`.
|
||
|
||
OAuth secrets (`x_fc_rtoken/x_fc_token/x_fc_token_validity/x_fc_sync_token`) are
|
||
`groups='base.group_system'` so non-admin users can't read them even on their own rows;
|
||
sync code uses `.sudo()` to access them.
|
||
|
||
---
|
||
|
||
## 14. Footguns & gotchas (read before editing)
|
||
|
||
1. **The silent-context flags are load-bearing.** Any time you create/write/unlink a
|
||
`calendar.event` *during sync or travel-block creation*, pass `_silent_ctx()` (or at
|
||
least `no_calendar_sync=True, dont_notify=True`). Otherwise the `calendar.event`
|
||
`write/unlink` overrides will try to **push back to external calendars** → pull → push
|
||
feedback loop and/or attendee emails. The whole sync path already does this; mirror it.
|
||
2. **MS delta `@removed` reason matters.** `@removed` with reason `'deleted'` (or
|
||
`isCancelled`) → archive + unlink. `@removed` with any other reason (typically
|
||
`'changed'`) → **return `'skipped'`, do NOT archive** — the event merely drifted out of
|
||
the delta window and still exists upstream. This exact distinction was the
|
||
`f1cea2fb` bug fix ("stop archiving valid events on @removed=changed"). Don't regress it.
|
||
3. **`cr.commit()` / `cr.rollback()` in the cron will raise inside `TransactionCase`.**
|
||
Per repo rule #14, Odoo 19 test cursors refuse commit/rollback. There are no tests today,
|
||
but if you add any that exercise `_cron_sync_all_accounts` / `sync_current_user`, refactor
|
||
to `with self.env.cr.savepoint():` per iteration instead of commit/rollback, or the test
|
||
cursor will break.
|
||
4. **Declarative SQL objects only** (rule #9): this module already uses
|
||
`models.Constraint(...)` for the unique constraints — keep that style, never
|
||
`_sql_constraints` or `init()`.
|
||
5. **`google_account`/`microsoft_account` ≠ native calendar sync.** Don't "simplify" by
|
||
reusing `google_calendar`/`microsoft_calendar` sync — this module intentionally owns its
|
||
OAuth + sync and hides the native UI. The native client-id params are only a credential
|
||
fallback.
|
||
6. **Public endpoints.** `/schedule/<slug>` and `/schedule/manage/<token>` are
|
||
`auth='public'`. The manage token is `secrets.token_hex(16)` (32 chars) and
|
||
`_get_event_by_token` enforces `len == 32`. Public booking requires both
|
||
`x_fc_booking_enabled=True` **and** the user having an `appointment.type` with them as
|
||
staff. Keep CSRF on the POST forms; the slot JSON-RPC endpoints are `csrf=False` by design.
|
||
7. **`data/appointment_invite_data.xml` is `noupdate=1`** and ships
|
||
`default_appointment_invite` with **empty** `appointment_type_ids` — the generic
|
||
`/book/book-appointment` share link won't resolve to a real type until configured. The
|
||
`/my/schedule` page separately resolves an `appointment.invite` by `staff_user_ids`.
|
||
8. **`data/mail_template_data.xml` is NOT `noupdate`** — the booking confirmation template
|
||
(`fusion_schedule_booking_confirmation`, on `calendar.event`) reloads on every `-u`.
|
||
It renders the manage link from `company.website or get_base_url()`.
|
||
9. **`graphify-out/` is a Cursor artifact**, not part of the module. It's not in the
|
||
manifest and Odoo never loads it. Safe to ignore or delete; don't treat its
|
||
`GRAPH_REPORT.md` as authoritative (it's a heuristic code-graph, ~87% extracted).
|
||
10. **Soft-dependency discipline.** Never assume `fusion_api` is installed — keep the
|
||
`try/except` + ICP fallback pattern in `_get_maps_api_key` / `_call_ai`. Adding
|
||
`fusion_api`/`fusion_claims` to `depends` would change the install graph; only do it
|
||
deliberately.
|
||
11. **Public booking does NOT re-validate the slot.** `schedule_book_submit` (authenticated)
|
||
re-runs `_generate_available_slots` and rejects a slot that's no longer free;
|
||
`public_book_submit` does **not** — it trusts the posted `slot_datetime`. Two visitors
|
||
hitting the same public slot can double-book. If you tighten this, add the same
|
||
re-validation to the public path.
|
||
12. **The two booking flows diverge** (§9.1): authenticated bookings are real `appointment`
|
||
events (`_prepare_calendar_event_values`); public bookings are raw `calendar.event`
|
||
rows. Reporting/automation that assumes every booking is an `appointment.type` booking
|
||
will miss public ones. Client-side changes must be made twice (asset file **and** the
|
||
inline script in `public_booking.xml`).
|
||
13. **`public_booking_page` references `today` but the controller never passes it.** The
|
||
template has `t-att-min="today"` on the date picker, yet
|
||
`PortalSchedule.public_booking_page()`'s values dict omits `today`. Either the website
|
||
render context happens to supply it or the `min` is silently empty (no past-date guard on
|
||
the public picker). **Verify / fix** by passing `today` from the controller if you touch
|
||
this page. (The authenticated book page correctly uses `now.strftime('%Y-%m-%d')`.)
|
||
14. **Public pages ignore the brand gradient.** They hardcode the default green/blue; only
|
||
the authenticated portal pages pick up `fusion_claims.portal_gradient_*`. If branding
|
||
must reach the public booking page, thread `portal_gradient` through
|
||
`public_booking_page` / `public_manage_page` values.
|
||
|
||
---
|
||
|
||
## 15. Deployment & history
|
||
|
||
- Built in **Cursor**; now maintained in Claude Code.
|
||
- Lives wherever **`fusion_portal`** lives (the authorizer/sales-rep portal — the **Westin**
|
||
Enterprise environment per the repo `CLAUDE.md` *Westin Prod* section). **Verify the
|
||
current target before shipping** — there's no in-module deploy note and nothing else
|
||
depends on it.
|
||
- Notable recent commits touching it:
|
||
- `f1cea2fb` — fix: stop archiving valid events on MS `@removed=changed` (the §14.2 bug).
|
||
- `747c8142` — `fusion_portal` renamed from `fusion_authorizer_portal` (this module's
|
||
`depends`/tile `inherit_id` already reference the **new** name `fusion_portal`).
|
||
- **Renaming the technical name** would require the full DB-rename procedure in repo rule #16
|
||
(it's a `fusion_*` module with external IDs, view keys, and a cron baked into the DB).
|
||
|
||
---
|
||
|
||
## 16. Audit findings — confirmed bugs, gaps & risks (2026-06-03 deep dive)
|
||
|
||
These were found by reading the code, not by running it. None are fixed yet — they're
|
||
recorded so the next change can address (or consciously accept) them. **The slot `datetime`
|
||
emitted by `_generate_available_slots` is UTC** (line 520: `slot_start_utc.strftime(...)`);
|
||
hold that fact while reading #1.
|
||
|
||
### 🔴 Bugs
|
||
|
||
1. **Timezone double-conversion on 3 of the 4 booking write-paths.** The slot's hidden
|
||
`datetime` is **UTC**, but only the authenticated *booking* path consumes it as UTC:
|
||
- ✅ `schedule_book_submit` (`portal_schedule.py:661`) — `datetime.strptime(...)` used
|
||
directly as UTC. **Correct.**
|
||
- ❌ `schedule_event_reschedule` (`:801–803`)
|
||
- ❌ `public_book_submit` (`:1505–1507`)
|
||
- ❌ `public_manage_reschedule` (`:889–891`)
|
||
|
||
The three ❌ paths do `tz.localize(naive).astimezone(utc)` — i.e. they treat an
|
||
already-UTC string as *local* and convert **again**, shifting the appointment by the
|
||
user's UTC offset. It is **silent when the resolved tz is UTC** (UTC server, no `tz`
|
||
cookie / `user.tz`), which is why it can pass casual testing — but with the
|
||
`tz`-cookie set by fusion_portal (e.g. `America/Toronto`, §4.5) a reschedule or **any**
|
||
public booking lands 4–5 h off. **Fix:** in those three paths, treat the slot string as
|
||
UTC exactly like `schedule_book_submit` (drop the `localize`/`astimezone`).
|
||
|
||
2. **Google pull is coupled to the server's OS timezone.** In
|
||
`_google_event_to_odoo_vals` (`fusion_calendar_account.py:530`):
|
||
`start_dt.astimezone(tz=None).replace(tzinfo=None)` — `astimezone(None)` converts an
|
||
aware datetime to the **system local** zone, not UTC. Odoo stores naive **UTC**, so
|
||
pulled Google events are correct **only if the container runs UTC**. The Microsoft path
|
||
parses as naive-UTC and is fine. **Fix:** `.astimezone(pytz.utc).replace(tzinfo=None)`.
|
||
|
||
3. **Public booking does not re-validate the slot** (`public_book_submit`) — see §14.11.
|
||
Combined with #1 it means the public path can both mis-time *and* double-book.
|
||
|
||
### 🟠 Gaps between documented intent and implementation
|
||
|
||
4. **"Busy on one, blocked on all" is enforced at *portal-booking time*, not by syncing
|
||
events between external calendars.** `_cross_calendar_push` **skips any event that
|
||
already has a link** (`if existing_links: continue`), and every *pulled* event has a
|
||
link — so a Google event is **never** pushed into the user's Outlook (and vice-versa).
|
||
What actually delivers "blocked on all" is `_generate_available_slots`, which searches
|
||
**all** of the user's `calendar.event` rows (everything pulled from every calendar) when
|
||
computing free slots — so booking **through `/my/schedule`** respects every connected
|
||
calendar. Booking *directly* in Google will not block Outlook. `_cross_calendar_push`
|
||
only mirrors **Odoo-native** events to the **first** active account. The manifest's
|
||
"busy on one, blocked on all" oversells the cross-external behaviour — state it as
|
||
*portal-booking-time* blocking.
|
||
|
||
### 🟡 Risks / abuse vectors
|
||
|
||
5. **Slug generation can block user creation.** `res.users.create` sets
|
||
`x_fc_schedule_slug` for **every** new user, guarded by `UNIQUE(x_fc_schedule_slug)`. The
|
||
4-hex suffix gives 1/65536 collision odds per name-base; a collision raises the
|
||
constraint and **fails the whole user-creation transaction** (no retry). Low probability,
|
||
high blast radius — consider a retry/uniqueness loop if user-creation volume grows.
|
||
6. **Unthrottled public booking.** `/schedule/<slug>/book` creates a `res.partner`, a
|
||
`calendar.event`, and force-sends an email for any visitor with **no captcha / rate
|
||
limit**. A scripted abuser can spam partners + events + outbound mail. Consider a
|
||
throttle / honeypot if the slug links are widely shared.
|
||
7. **Synchronous external HTTP inside `calendar.event.write()/unlink()`.** Because
|
||
fusion_schedule is the **sole** `calendar.event` extender (verified — see below), its
|
||
overrides fire for **every** event in the system. For a *linked* event, a write that
|
||
touches a sync field makes a **blocking** Google/Microsoft API call inside the caller's
|
||
transaction; a bulk write/delete over many linked events ⇒ N serial HTTP round-trips,
|
||
potentially stalling that request/transaction. Keep this in mind before bulk-editing
|
||
calendar events in any module.
|
||
|
||
### 🔬 Deep-dive #5 additions — sync-dedup cluster + public-endpoint security
|
||
|
||
Found by an adversarial re-read (all verified against code). Full detail + fixes in Supabase
|
||
`fusionapps.issues` (project Fusion Schedule). The **dedup cluster (8–10) is the most serious
|
||
— it corrupts data across users**:
|
||
|
||
8. **🔴 `_find_existing_event` merges events across users + resurrects archived ones.**
|
||
`fusion_calendar_account.py:401-417` dedups by **name+start+stop only**, on `.sudo()`
|
||
(record rules bypassed), **unscoped** by user/partner/company. Two staff with a same-titled
|
||
same-time event (Standup, Lunch, an org-wide invite) → user B's sync **reuses user A's
|
||
`calendar.event`** and links B's account onto it; also **reactivates a deliberately-archived
|
||
event**. Runs as root in cron → crosses companies. Fix: scope to
|
||
`partner_ids in [self.x_fc_user_id.partner_id]` + `x_fc_source_account_id in [self.id, False]`;
|
||
never auto-reactivate an event with no surviving link to this account.
|
||
9. **🔴 iCalUID cross-link is unscoped.** `fusion_calendar_account.py:482-489` (Google) /
|
||
`715-724` (MS) match `x_fc_universal_id` across **all** accounts/users. A real invite sent to
|
||
two staff shares one iCalUID → user B's account links onto user A's event; B never gets their
|
||
own row. Fix: scope the lookup to `x_fc_account_id.x_fc_user_id = self.x_fc_user_id.id`.
|
||
10. **🔴 No per-row isolation in the sync loop.** `_sync_pull_google/_microsoft` loop
|
||
`_process_*_event` with no savepoint and write `sync_token` **after** the loop. One row
|
||
exception (e.g. an IntegrityError — `_upsert_event_link` branches on `(account,event_id)` at
|
||
`:419-445` but the UNIQUE is `(account,external_id)` at `fusion_calendar_event_link.py:32`)
|
||
rolls back the whole page and **never advances `sync_token`** → deterministic errors wedge
|
||
the account forever. Fix: `with self.env.cr.savepoint():` per row; branch the upsert on
|
||
`(account, external_id)`.
|
||
11. **🔴 MS delta page-cap stalls large calendars.** `_sync_pull_microsoft` caps at 2000/5000
|
||
and `break`s without the `@odata.deltaLink` (`:601-606`), writing back the old token → a
|
||
>2000-event window re-fetches the same 2000 forever and never delivers the rest. The
|
||
410/`fullSyncRequired` recursion (`:318-321`, `:588-591`) has **no depth guard**.
|
||
12. **🟡 Public booking mutates/attaches an existing partner by email.**
|
||
`public_book_submit` (`portal_schedule.py:1516-1525`) does
|
||
`Partner.search([('email','=ilike', visitor_email)])` then writes `phone` onto the match and
|
||
attaches it as an attendee. An anonymous visitor can pollute an arbitrary contact (incl.
|
||
staff), pull internal partners into an event, and mail arbitrary addresses. Fix: on the
|
||
public path, never mutate/attach a partner matched only by attacker-supplied email.
|
||
13. **🟡 Manage-token leaks via redirect URL + no re-validation + no throttle.** The success
|
||
redirect puts the 32-char bearer token in an in-page URL query string
|
||
(`portal_schedule.py:1590-1594`) → leaks via history + `Referer` to Google Maps assets.
|
||
`public_manage_reschedule` (`:876-903`) also skips slot re-validation; public routes are
|
||
unthrottled. (Token entropy itself is fine.) Fix: keep the token in the emailed link only,
|
||
add `Referrer-Policy: no-referrer`, re-validate, throttle.
|
||
14. **🟡 `sync_current_user` commits mid-loop** (`:1097`) — non-atomic inside an interactive
|
||
RPC; reports `{success: False}` after already persisting earlier accounts.
|
||
15. **🟡 Dead imports** trip pyflakes: `import secrets` (`calendar_event.py:4`) and
|
||
`import hashlib` (`controllers/portal_schedule.py:4`) are unused. (`res_users.py` is fine —
|
||
it uses `uuid`.)
|
||
|
||
> Refinement to #4: `_cross_calendar_push` is **also** gated by `len(user_accounts) > 1`
|
||
> (`:1149`), so **single-account users never get their Odoo-native events pushed out at all**,
|
||
> and the `start >= now-1d` filter excludes all-day events. So even the portal-side mirroring is
|
||
> partial.
|
||
|
||
### 🧱 Deep-dive #6 — install / render / Odoo-19-API correctness (the AI-codegen layer)
|
||
|
||
**Clean meta-result:** a grep for every repo-documented Odoo-19 anti-pattern came back empty —
|
||
no `type="json"`, `groups_id`, `_sql_constraints`, `numbercall`, `useService('rpc')`,
|
||
`category_id`, `fields.Date` in settings, or SCSS `@import`; `models.Constraint`/`models.Index`,
|
||
`@api.model_create_multi`, the OWL import path, and route types are all **correct** Odoo 19. So
|
||
the AI (Cursor + Claude 4.5 Opus) got the *syntax/idioms* right; the defects are semantic
|
||
(logic/integration/tz), plus these render/version items:
|
||
|
||
16. **🟡 `today` undefined on the public booking page.** `public_booking.xml:79`
|
||
(`t-att-min="today"`) but `public_booking_page` (`:1418-1426`) never passes `today`
|
||
(the authenticated page correctly passes `now`). At minimum the public date picker loses its
|
||
min-date guard (visitor can pick a past date → server returns 0 slots). **Confirm on Odoo 19
|
||
whether QWeb omits the attr or 500s** — the public page looks untested. Copy-paste drift.
|
||
17. **🟡 Confirmation email renders UTC times + wrong language.** `mail_template_data.xml`
|
||
`t-out object.start/stop` with the `datetime` widget renders in the **renderer's tz** (UTC on
|
||
`force_send` from a portal request) → email shows UTC, not the client's local time. And
|
||
`lang = {{ object.partner_ids[:1].lang }}` picks the **first** partner = the **staff** user,
|
||
not the client. (Mail body is otherwise rule-17-safe — no `url_encode`/undefined names;
|
||
`res.company.website` + `get_base_url()` resolve.)
|
||
18. **🟡 Address-autocomplete drift.** The asset JS stores province as full name
|
||
(`portal_schedule_booking.js:546`, `long_name` → "Ontario"); the public inline JS stores the
|
||
2-letter code (`public_booking.xml:318`, `short_name` → "ON"). Same field, two formats. The
|
||
asset version also omits the Places `fields:[...]` filter → Google all-fields billing tier.
|
||
19. **🟠 `_prepare_calendar_event_values` signature unverified.** `portal_schedule.py:717-730`
|
||
calls this **private Enterprise** method (signature shifted across 16→19). A mismatched kwarg
|
||
raises `TypeError`, swallowed by the `except` at `:766` → **authenticated bookings silently
|
||
never get created**. The public path builds vals by hand (a tell). **Needs a booking
|
||
smoke-test on Enterprise** — couldn't byte-verify (Docker/Odoo source unreachable).
|
||
|
||
**Version-fragility notes (work now, but verify on Odoo point-upgrades — not logged as bugs):**
|
||
- The backend patch xpaths `//div[@id='header_synchronization_settings']`
|
||
(`fusion_calendar_controller.xml:10,15`) against `calendar.AttendeeCalendarController`. It
|
||
resolves on the deployed version (else the *entire* `web.assets_backend` bundle would be dead),
|
||
but a future Odoo restructure of that template would brick the bundle. Prefer a stabler
|
||
selector when next touched.
|
||
- The `appointment.invite` seed (`appointment_invite_data.xml:8`) has empty
|
||
`appointment_type_ids` **and** no `staff_user_ids`, so `schedule_page`'s `share_url`
|
||
(`invite.book_url`) never resolves for anyone — the seed is inert (the `/schedule/<slug>` flow
|
||
is the real share). Reconcile or drop it.
|
||
|
||
### ✅ Audit results that came back clean (good to know)
|
||
|
||
- **No `x_fc_*` field-name collisions.** None of `x_fc_schedule_slug / _booking_enabled /
|
||
_work_start / _work_end / _break_start / _travel_buffer / _home_address / _home_lat`
|
||
appears in any other module.
|
||
- **`calendar.event` is inherited by `fusion_schedule` alone** (whole repo). Its
|
||
`write/unlink` overrides are the only custom hooks on that model — but they run for every
|
||
calendar event once installed (see risk #7).
|
||
- **No conflicting `res.users.create()` override in the dependency chain.** `fusion_portal`
|
||
only overrides `_generate_tutorial_articles` / `portal.wizard.user`; `fusion_tasks` adds
|
||
`x_fc_is_field_staff / x_fc_start_address / x_fc_tech_sync_id` (no `create`, no overlap).
|
||
So the `@api.model_create_multi create()` slug hook chains cleanly via `super()`.
|
||
|
||
---
|
||
|
||
## 17. File index
|
||
|
||
```
|
||
fusion_schedule/
|
||
├── __manifest__.py # deps, data load order, assets (v19.0.2.1.0)
|
||
├── controllers/portal_schedule.py # ALL routes + slot gen + travel + AI + OAuth (~1600 lines)
|
||
├── models/
|
||
│ ├── fusion_calendar_account.py # OAuth + sync engine (the core)
|
||
│ ├── fusion_calendar_event_link.py # Odoo↔external join (unique per account)
|
||
│ ├── calendar_event.py # inherit: source/links/manage-token/travel + write/unlink push
|
||
│ ├── res_users.py # inherit: slug, booking flag, work prefs, auto-slug
|
||
│ └── res_config_settings.py # OAuth creds + sync interval + schedule defaults
|
||
├── data/
|
||
│ ├── ir_cron_data.xml # 5-min sync cron
|
||
│ ├── mail_template_data.xml # booking confirmation email (NOT noupdate)
|
||
│ └── appointment_invite_data.xml # default share invite (noupdate, empty types)
|
||
├── security/{security.xml, ir.model.access.csv}
|
||
├── views/
|
||
│ ├── fusion_calendar_account_views.xml # backend list/form + Technical menu
|
||
│ ├── res_config_settings_views.xml # Settings app block
|
||
│ ├── portal_schedule_tile.xml # tile into fusion_portal.portal_my_home_authorizer
|
||
│ ├── portal_schedule.xml # portal_schedule_page + portal_schedule_book
|
||
│ └── public_booking.xml # public_booking_page + public_manage_page
|
||
├── static/src/
|
||
│ ├── css/portal_schedule.css
|
||
│ ├── js/portal_schedule_booking.js # booking form + Places autocomplete + AI suggest
|
||
│ ├── js/portal_schedule_accounts.js # dashboard modals/toasts + optimize
|
||
│ └── views/fusion_calendar_controller.{js,xml} # backend calendar patch
|
||
├── utils/__init__.py # empty placeholder
|
||
└── graphify-out/ # Cursor code-graph artifact — NOT loaded by Odoo
|
||
```
|