CHANGES
This commit is contained in:
799
fusion_schedule/CLAUDE.md
Normal file
799
fusion_schedule/CLAUDE.md
Normal file
@@ -0,0 +1,799 @@
|
||||
# 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
|
||||
```
|
||||
386
fusion_schedule/CODE_MAP.md
Normal file
386
fusion_schedule/CODE_MAP.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# fusion_schedule — CODE MAP (where-is-what index)
|
||||
|
||||
> Precise symbol-level index for the whole module. Companion to `CLAUDE.md` (which is the
|
||||
> narrative/guidance doc). **This file = "where is X".** Line numbers are exact at the time of
|
||||
> writing (2026-06-03); re-grep `def `/`fields.`/`@http.route`/`<template id=` if they drift.
|
||||
> Audit findings live in `CLAUDE.md §16` and in Supabase `fusionapps.issues`
|
||||
> (project **Fusion Schedule** = `576de219-57e6-4596-8c8c-0c093e4cb54a`).
|
||||
|
||||
## 0. File tree (sizes approximate, by cat -n)
|
||||
|
||||
```
|
||||
fusion_schedule/
|
||||
├── __manifest__.py 61 deps, data load order, assets (v19.0.2.1.0)
|
||||
├── __init__.py 3 → controllers, models
|
||||
├── controllers/
|
||||
│ ├── __init__.py 2 → portal_schedule
|
||||
│ └── portal_schedule.py ~1607 PortalSchedule(CustomerPortal): 23 routes + helpers
|
||||
├── models/
|
||||
│ ├── __init__.py 6 load order (see below)
|
||||
│ ├── fusion_calendar_account.py ~1191 sync engine + OAuth + cron (THE core)
|
||||
│ ├── fusion_calendar_event_link.py 30 Odoo↔external join table
|
||||
│ ├── calendar_event.py 89 inherit: sync fields + write/unlink push
|
||||
│ ├── res_users.py 69 inherit: slug + work prefs + auto-slug create()
|
||||
│ └── res_config_settings.py 74 inherit: OAuth creds + sync interval + defaults
|
||||
├── data/
|
||||
│ ├── ir_cron_data.xml 13 5-min sync cron
|
||||
│ ├── mail_template_data.xml 155 booking confirmation email
|
||||
│ └── appointment_invite_data.xml 10 default share invite (noupdate)
|
||||
├── security/
|
||||
│ ├── security.xml 17 2 record rules
|
||||
│ └── ir.model.access.csv 5 4 ACL rows
|
||||
├── views/
|
||||
│ ├── fusion_calendar_account_views.xml 64 backend list/form/action/menu
|
||||
│ ├── res_config_settings_views.xml 148 Settings app block
|
||||
│ ├── portal_schedule_tile.xml 25 tile into fusion_portal home
|
||||
│ ├── portal_schedule.xml 833 portal_schedule_page + portal_schedule_book
|
||||
│ └── public_booking.xml 586 public_booking_page + public_manage_page (inline JS)
|
||||
├── static/src/
|
||||
│ ├── css/portal_schedule.css 48 responsive helpers only
|
||||
│ ├── js/portal_schedule_booking.js ~575 booking form (authenticated)
|
||||
│ ├── js/portal_schedule_accounts.js ~489 dashboard modals/toasts/optimize
|
||||
│ └── views/fusion_calendar_controller.{js,xml} 68/44 backend AttendeeCalendarController patch
|
||||
├── utils/__init__.py 1 empty placeholder
|
||||
└── graphify-out/ — Cursor artifact, NOT loaded by Odoo
|
||||
```
|
||||
Model load order (`models/__init__.py`): `fusion_calendar_account → fusion_calendar_event_link
|
||||
→ calendar_event → res_users → res_config_settings`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Models
|
||||
|
||||
### 1.1 `fusion.calendar.account` — `models/fusion_calendar_account.py`
|
||||
`_order = 'x_fc_provider, x_fc_email'`. Module constants (top of file): `TIMEOUT=20` (14),
|
||||
`MAX_THROTTLE_RETRIES=3` (15), `DEFAULT_RETRY_SECONDS=10` (16); Google endpoints 19–23,
|
||||
Microsoft endpoints 26–34.
|
||||
|
||||
**Fields**
|
||||
| line | field | type / notes |
|
||||
|---|---|---|
|
||||
| 42 | `x_fc_user_id` | m2o res.users · required · cascade · default=current user · index |
|
||||
| 46 | `x_fc_provider` | sel google/microsoft · required |
|
||||
| 50 | `x_fc_email` | char |
|
||||
| 51 | `x_fc_name` | char · compute `_compute_name` · store |
|
||||
| 52 | `x_fc_active` | bool · default True |
|
||||
| 55 | `x_fc_rtoken` | char · **groups=base.group_system** |
|
||||
| 56 | `x_fc_token` | char · **group_system** |
|
||||
| 57 | `x_fc_token_validity` | datetime · **group_system** |
|
||||
| 60 | `x_fc_sync_token` | char · **group_system** (delta/sync token) |
|
||||
| 61 | `x_fc_calendar_id` | char · default `'primary'` |
|
||||
| 62 | `x_fc_last_sync` | datetime |
|
||||
| 63 | `x_fc_sync_status` | sel active/error/paused · default active |
|
||||
| 68 | `x_fc_error_message` | text |
|
||||
| 71 | `x_fc_link_ids` | o2m → fusion.calendar.event.link |
|
||||
|
||||
**Methods**
|
||||
| line | method | purpose |
|
||||
|---|---|---|
|
||||
| 76 | `_compute_name` | "Provider — email" label |
|
||||
| 85/92/99/106 | `_get_{google,microsoft}_client_{id,secret}` | creds: `fusion_schedule_*` ICP → native `*_calendar_*` ICP fallback |
|
||||
| 117 | `_get_valid_token` | return token, refresh if <1 min to expiry |
|
||||
| 130 | `_refresh_token` | dispatch to provider refresh; on 400/401 mark error + clear |
|
||||
| 149 / 170 | `_refresh_google_token` / `_refresh_microsoft_token` | OAuth refresh (MS may rotate rtoken) |
|
||||
| 200 / 213 | `_exchange_{google,microsoft}_code` | code→tokens (called from callback) |
|
||||
| 232 / 243 | `_fetch_{google,microsoft}_email` | `@api.model` · whoami email |
|
||||
| 258 | `_sync_pull` | entry: dispatch pull per provider, catch+record errors |
|
||||
| 293 | `_sync_pull_google` | events.list paging; 410→drop token+full resync; window −14/+30d |
|
||||
| 362 | `_google_request_with_retry` | GET w/ 429/503 + connection retry |
|
||||
| 389 | `_silent_ctx` | context flags that suppress mail + re-push (load-bearing) |
|
||||
| 401 | `_find_existing_event` | dedup by name+start+stop (incl. archived) |
|
||||
| 419 | `_upsert_event_link` | create/update the join row |
|
||||
| 447 | `_process_google_event` | upsert one Google event (3-tier dedup) |
|
||||
| 503 | `_google_event_to_odoo_vals` | ⚠ uses `astimezone(None)` — server-tz bug (CLAUDE §16.2) |
|
||||
| 550 | `_sync_pull_microsoft` | Graph `calendarView/delta`; page cap 2000/5000 |
|
||||
| 642 | `_microsoft_request_with_retry` | GET w/ retry |
|
||||
| 671 | `_process_microsoft_event` | upsert one MS event; `@removed=changed`→`'skipped'` (don't archive) |
|
||||
| 738 | `_microsoft_event_to_odoo_vals` | MS dict→Odoo vals |
|
||||
| 798 | `_fetch_microsoft_event_subject` | fallback fetch when delta omits subject |
|
||||
| 821 | `_sync_push_event` | push one Odoo event (insert/patch per provider) |
|
||||
| 870/884/896 | `_google_{insert,patch,delete}_event` | Google write API |
|
||||
| 908/924/938 | `_microsoft_{insert,patch,delete}_event` | Graph write API |
|
||||
| 953 / 977 | `_odoo_event_to_{google,microsoft}` | Odoo→external format |
|
||||
| 1022 | `_cross_calendar_push` | push **unlinked Odoo-native** events to **first** account (CLAUDE §16.4) |
|
||||
| 1066 | `get_user_accounts_status` | `@api.model` **[backend RPC]** — account chips |
|
||||
| 1081 | `sync_current_user` | `@api.model` **[backend RPC]** — "Sync now" (commits per account) |
|
||||
| 1116 | `_cron_sync_all_accounts` | `@api.model` **[cron]** — sync all, then cross-push per multi-acct user |
|
||||
| 1161 | `action_disconnect` | delete pushed external events, unlink, pause |
|
||||
|
||||
### 1.2 `fusion.calendar.event.link` — `models/fusion_calendar_event_link.py`
|
||||
`_order = 'x_fc_last_synced desc'`. Fields: `x_fc_event_id` (11, m2o calendar.event, cascade),
|
||||
`x_fc_account_id` (15, m2o account, cascade), `x_fc_external_id` (19, req, index),
|
||||
`x_fc_universal_id` (22, iCalUID, index), `x_fc_last_synced` (25), `x_fc_sync_direction`
|
||||
(26, pull/push/both). **Constraint** `_unique_account_external` = `UNIQUE(x_fc_account_id,
|
||||
x_fc_external_id)` (32).
|
||||
|
||||
### 1.3 `calendar.event` (inherit) — `models/calendar_event.py`
|
||||
**Sole extender of `calendar.event` in the whole repo.** Fields: `x_fc_source_account_id`
|
||||
(14), `x_fc_is_external` (18, compute+store), `x_fc_link_ids` (21), `x_fc_manage_token`
|
||||
(24, index, copy=False), `x_fc_client_email` (28), `x_fc_client_phone` (29),
|
||||
`x_fc_address_lat` (30), `x_fc_address_lng` (31), `x_fc_travel_minutes_before` (32),
|
||||
`x_fc_is_travel_block` (36). Methods: `_compute_is_external` (42), `_skip_fc_sync` (46),
|
||||
`unlink` (51, deletes from external), `write` (76, pushes to external) — both gated by
|
||||
`_skip_fc_sync()` + presence of links. ⚠ external HTTP is synchronous (CLAUDE §16.7).
|
||||
|
||||
### 1.4 `res.users` (inherit) — `models/res_users.py`
|
||||
Fields: `x_fc_calendar_account_ids` (12), `x_fc_schedule_slug` (16), `x_fc_booking_enabled`
|
||||
(21), `x_fc_work_start` (26), `x_fc_work_end` (30), `x_fc_break_start` (34),
|
||||
`x_fc_break_duration` (38), `x_fc_travel_buffer` (42), `x_fc_home_address` (46),
|
||||
`x_fc_home_lat` (50), `x_fc_home_lng` (51). **Constraint** `_unique_schedule_slug` =
|
||||
`UNIQUE(x_fc_schedule_slug)` (53). Methods: `create` (59, `@api.model_create_multi`,
|
||||
auto-slug — ⚠ collision risk CLAUDE §16.5), `_generate_schedule_slug` (66).
|
||||
|
||||
### 1.5 `res.config.settings` (inherit) — `models/res_config_settings.py`
|
||||
Fields (12): `x_fc_google_client_id` (10), `_secret` (14), `_has_fallback` (18);
|
||||
`x_fc_microsoft_client_id` (24), `_secret` (28), `_has_fallback` (32);
|
||||
`x_fc_sync_interval_minutes` (38, **not wired to cron**); `x_fc_default_work_start` (45),
|
||||
`_work_end` (50), `_break_start` (55), `_break_duration` (60), `_travel_buffer` (65).
|
||||
Methods: `_compute_google_has_fallback` (72), `_compute_microsoft_has_fallback` (79).
|
||||
|
||||
---
|
||||
|
||||
## 2. Controller — `controllers/portal_schedule.py` (`PortalSchedule(CustomerPortal)`)
|
||||
|
||||
**Helper methods**
|
||||
| line | method | purpose |
|
||||
|---|---|---|
|
||||
| 30 | `_get_schedule_values` | portal gradient (fusion_claims params) + maps key |
|
||||
| 43 | `_get_user_timezone` | → `_resolve_timezone(env.user)` |
|
||||
| 46 | `_resolve_timezone` | user.tz → `tz` cookie → company cal → UTC |
|
||||
| 69 | `_get_appointment_types` | types where current user is staff |
|
||||
| 75 | `_get_user_prefs` | per-user prefs w/ company-default fallback |
|
||||
| 101 | `_get_maps_api_key` | `fusion.api.service` → `fusion_claims.google_maps_api_key` |
|
||||
| 114 | `_call_ai` | `fusion.api.service.call_openai` → direct OpenAI HTTP |
|
||||
| 147 | `_get_travel_time` | Google Distance Matrix (min) |
|
||||
| 178 | `_geocode_address` | Google Geocoding (lat,lng) |
|
||||
| 200 | `_create_travel_blocks` | insert "Travel to …" placeholder events |
|
||||
| 425 | `_format_hour` | staticmethod · 13.5 → "1:30 PM" |
|
||||
| 435 | `_generate_available_slots` | **shared slot core**; emits UTC `datetime` (line 520) |
|
||||
| 825 | `_get_event_by_token` | manage-token lookup (len==32) |
|
||||
| 932 | `_build_schedule_context` | AI prompt context builder |
|
||||
| 1336 | `_find_recently_connected_account` | OAuth callback resilience |
|
||||
|
||||
**Routes** (23 total)
|
||||
| line | verb | path | auth | handler |
|
||||
|---|---|---|---|---|
|
||||
| 288 | http | `/my/schedule` | user | `schedule_page` |
|
||||
| 363 | jsonrpc | `/my/schedule/preferences` | user | `schedule_save_preferences` |
|
||||
| 397 | http | `/my/schedule/book` | user | `schedule_book` |
|
||||
| 530 | jsonrpc | `/my/schedule/available-slots` | user | `schedule_available_slots` |
|
||||
| 560 | jsonrpc | `/my/schedule/week-events` | user | `schedule_week_events` |
|
||||
| 630 | http POST | `/my/schedule/book/submit` | user | `schedule_book_submit` ✅ tz-correct |
|
||||
| 777 | jsonrpc | `/my/schedule/event/cancel` | user | `schedule_event_cancel` |
|
||||
| 792 | jsonrpc | `/my/schedule/event/reschedule` | user | `schedule_event_reschedule` ⚠ tz-bug |
|
||||
| 834 | http | `/schedule/manage/<token>` | public | `public_manage_page` |
|
||||
| 860 | http POST | `/schedule/manage/<token>/cancel` | public | `public_manage_cancel` |
|
||||
| 876 | http POST | `/schedule/manage/<token>/reschedule` | public | `public_manage_reschedule` ⚠ tz-bug |
|
||||
| 907 | jsonrpc | `/schedule/manage/<token>/available-slots` | public (csrf=False) | `public_manage_slots` |
|
||||
| 982 | jsonrpc | `/my/schedule/ai/suggest` | user | `schedule_ai_suggest` |
|
||||
| 1093 | jsonrpc | `/my/schedule/ai/optimize` | user | `schedule_ai_optimize` |
|
||||
| 1155 | http | `/my/schedule/connect/google` | user | `connect_google` |
|
||||
| 1192 | http | `/my/schedule/connect/microsoft` | user | `connect_microsoft` |
|
||||
| 1230 | http | `/my/schedule/oauth/callback` | user | `oauth_callback` |
|
||||
| 1356 | jsonrpc | `/my/schedule/disconnect` | user | `schedule_disconnect` |
|
||||
| 1370 | jsonrpc | `/my/schedule/sync-now` | user | `schedule_sync_now` |
|
||||
| 1398 | http | `/schedule/<slug>` | public | `public_booking_page` |
|
||||
| 1431 | jsonrpc | `/schedule/<slug>/available-slots` | public (csrf=False) | `public_available_slots` |
|
||||
| 1465 | http POST | `/schedule/<slug>/book` | public (csrf) | `public_book_submit` ⚠ tz-bug + no re-validate |
|
||||
| 1602 | jsonrpc | `/my/schedule/toggle-booking` | user | `schedule_toggle_booking` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend JS
|
||||
|
||||
### 3.1 backend patch — `static/src/views/fusion_calendar_controller.js`
|
||||
`patch(AttendeeCalendarController.prototype)`: `setup`, getters `fusionAccounts` /
|
||||
`fusionSyncing`, `_loadFusionAccounts` (→ `get_user_accounts_status`), `onFusionSyncNow`
|
||||
(→ `sync_current_user`). Template `.xml` hides `#header_synchronization_settings`, injects
|
||||
account chips + sync button + cog→`/my/schedule`.
|
||||
|
||||
### 3.2 `static/src/js/portal_schedule_booking.js` (authenticated book page)
|
||||
`setTzCookie` (4), `getAppointmentTypeId` (35), `truncate` (41), `formatDateStr` (46),
|
||||
`addDays` (53), `getMonday` (59), `selectDay` (67), `fetchWeekEvents` (77) →
|
||||
`/my/schedule/week-events`, `navigateWeek` (120), `renderWeekCalendar` (140), `fetchSlots`
|
||||
(260) → `/my/schedule/available-slots`, `renderGroup` (319, nested), `fetchAiSuggestions`
|
||||
(364) → `/my/schedule/ai/suggest`, `setupAddressAutocomplete` (516, Google Places).
|
||||
|
||||
### 3.3 `static/src/js/portal_schedule_accounts.js` (dashboard)
|
||||
Utils: `localDateStr` (4), `setTzCookie` (12), `jsonRpc` (21), `fusionConfirm` (30),
|
||||
`fusionToast` (87, builds `#fusionToastLive` — template `#fusionToast` is dead),
|
||||
`closeRescheduleModal` (304), `closeOptimizeModal` (474). Event bindings: disconnect (112)
|
||||
→ `/disconnect`, sync (141) → `/sync-now`, share (160), save-prefs (186) → `/preferences`,
|
||||
cancel (231) → `/event/cancel`, reschedule open (274) + date-change (321) +
|
||||
confirm (375) → `/event/reschedule`, optimize (413) → `/ai/optimize`.
|
||||
|
||||
> Public pages (`public_booking_page`, `public_manage_page`) carry their **own inline
|
||||
> `<script>`** in `public_booking.xml` (a 2nd copy of slot-render + Places autocomplete +
|
||||
> reschedule) — they do **not** use the files above. See CLAUDE §9.1.
|
||||
|
||||
### 3.4 DOM-id contract (templates ↔ JS)
|
||||
Book page: `bookingDate`, `appointmentTypeSelect`, `slotsContainer/slotsGrid/slotsLoading/
|
||||
noSlots`, `slotDatetime`, `slotDuration`, `weekCalendar{Container,Loading,Grid,Header,Body,
|
||||
Empty,Nav}`, `btnPrevWeek/btnNextWeek/weekRangeLabel`, `aiSuggest{Section,Loading,Grid}`,
|
||||
`btnAiSuggest`, `clientStreet/City/Province/Postal/Lat/Lng`, `btnSubmitBooking`.
|
||||
Dashboard: `fusionConfirmModal`(+Title/Message/Ok), `rescheduleModal`(+Date/SlotsContainer/
|
||||
SlotsGrid/EventId/SlotDatetime/EventDuration/EventName/AppTypeId/btnConfirmReschedule),
|
||||
`optimizeModal`(+Loading/Result/CurrentTravel/NewTravel/Savings/ScheduleList/Error),
|
||||
`schedulePrefsForm`/`btnSavePrefs`/`prefsSavedMsg`, `btnOptimizeSchedule`, `.js-*` classes.
|
||||
Public: `publicBookingDate`, `publicSlots*`, `publicSlotDatetime/Duration`, `publicBtnSubmit`,
|
||||
`publicAppointmentType`, `publicClient*`, `publicReschedule*`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Templates / data / security / settings
|
||||
|
||||
**Templates**
|
||||
| id | file:line | base layout |
|
||||
|---|---|---|
|
||||
| `portal_schedule_page` | portal_schedule.xml:6 | `portal.portal_layout` |
|
||||
| `portal_schedule_book` | portal_schedule.xml:605 | `portal.portal_layout` |
|
||||
| `public_booking_page` | public_booking.xml:6 | `website.layout` (+inline JS) |
|
||||
| `public_manage_page` | public_booking.xml:345 | `website.layout` (+inline JS) |
|
||||
| `portal_my_home_schedule` | portal_schedule_tile.xml:5 | inherit `fusion_portal.portal_my_home_authorizer` |
|
||||
| `FusionCalendarController` | fusion_calendar_controller.xml | t-inherit `calendar.AttendeeCalendarController` |
|
||||
| `res_config_settings_view_form_fusion_schedule` | res_config_settings_views.xml:4 | inherit `base.res_config_settings_view_form` |
|
||||
|
||||
**Backend views** — `fusion_calendar_account_views.xml`: list (5), form (24),
|
||||
`action_fusion_calendar_account` (56), `menu_fusion_calendar_account` (63, under
|
||||
`base.menu_custom`).
|
||||
|
||||
**Data** — `ir_cron_fusion_calendar_sync` (ir_cron_data.xml:4, 5 min, `_cron_sync_all_accounts`);
|
||||
`fusion_schedule_booking_confirmation` (mail_template_data.xml:4, model `calendar.event`,
|
||||
NOT noupdate); `default_appointment_invite` (appointment_invite_data.xml:8, noupdate,
|
||||
short_code `book-appointment`, empty types).
|
||||
|
||||
**Security** — rules `fusion_calendar_account_user_rule` (security.xml:5),
|
||||
`fusion_calendar_event_link_user_rule` (security.xml:13); ACL: 4 rows in
|
||||
`ir.model.access.csv` (account: CRUD user / none public; link: CRU user / full system).
|
||||
|
||||
---
|
||||
|
||||
## 5. Config parameters (`ir.config_parameter`)
|
||||
|
||||
**Owned** — `fusion_schedule_google_client_id`, `_google_client_secret`,
|
||||
`fusion_schedule_microsoft_client_id`, `_microsoft_client_secret`,
|
||||
`fusion_schedule_sync_interval`; `fusion_schedule.default_work_start` / `_work_end` /
|
||||
`_break_start` / `_break_duration` / `_travel_buffer`.
|
||||
**Fallback (not owned)** — `google_calendar_client_id` / `_secret`,
|
||||
`microsoft_calendar_client_id` / `_secret`, `web.base.url`; and the fusion_claims namespace
|
||||
`fusion_claims.portal_gradient_start/_mid/_end`, `fusion_claims.google_maps_api_key`,
|
||||
`fusion_claims.ai_api_key`.
|
||||
|
||||
---
|
||||
|
||||
## 6. External HTTP it talks to
|
||||
|
||||
- **Google** OAuth (`accounts.google.com/o/oauth2/auth`, `oauth2.googleapis.com/token`),
|
||||
Calendar v3 (`googleapis.com/calendar/v3`), userinfo, Distance Matrix + Geocoding
|
||||
(`maps.googleapis.com`). Scopes: `calendar` + `userinfo.email`.
|
||||
- **Microsoft** OAuth (`login.microsoftonline.com/common/oauth2/v2.0/*`), Graph
|
||||
(`graph.microsoft.com/v1.0` — `me/calendarView/delta`, `me/events`, `me`). Scopes:
|
||||
`offline_access openid Calendars.ReadWrite User.Read`.
|
||||
- **OpenAI** `api.openai.com/v1/chat/completions` (`gpt-4o-mini`) — fallback only.
|
||||
|
||||
---
|
||||
|
||||
## 7. Cross-module touchpoints (full detail in CLAUDE §4)
|
||||
|
||||
| direction | what | where |
|
||||
|---|---|---|
|
||||
| depends ↓ | `fusion_portal` (→ fusion_claims stack) | __manifest__.py:35 |
|
||||
| inherit ↓ | `fusion_portal.portal_my_home_authorizer` | portal_schedule_tile.xml:6 |
|
||||
| soft-call ↓ | `fusion.api.service` (fusion_api) | portal_schedule.py:104,117 |
|
||||
| ICP read ↓ | `fusion_claims.{portal_gradient_*,google_maps_api_key,ai_api_key}` | portal_schedule.py:33-35,111,126 |
|
||||
| cookie ← | `tz` set by `fusion_portal/.../timezone_detect.js` | portal_schedule.py:_resolve_timezone |
|
||||
| shared table | `calendar.event` (also written by fusion_claims schedule wizard / appointment) | models/calendar_event.py |
|
||||
| reverse | **none** (only fusion_repairs lists it as *deferred*) | — |
|
||||
|
||||
---
|
||||
|
||||
## 8. Audit cross-reference
|
||||
|
||||
**19 findings** logged → Supabase `fusionapps.issues`, project **Fusion Schedule**
|
||||
(`576de219-57e6-4596-8c8c-0c093e4cb54a`), all `status='open'`. Detail + fixes in
|
||||
`CLAUDE.md §16` (deep dives #1–#6). Provenance: AI-generated (Cursor + Claude 4.5 Opus) —
|
||||
Odoo-19 syntax clean, bugs are semantic. Headlines: (a) timezone double-conversion on `schedule_event_reschedule` /
|
||||
`public_book_submit` / `public_manage_reschedule` (slot string is UTC but they re-localize);
|
||||
(b) the **sync-dedup cluster** — `_find_existing_event` (`:401`) and the iCalUID lookup
|
||||
(`:482`/`:715`) are unscoped by user, so same-titled / shared-invite events **merge across
|
||||
different users** and resurrect archived ones; (c) public booking mutates an existing
|
||||
`res.partner` by attacker-supplied email.
|
||||
|
||||
---
|
||||
|
||||
## 9. Consumed contracts — the OTHER side of each cross-module link (integration boundary)
|
||||
|
||||
### 9.1 `fusion.api.service` broker (`fusion_api`, **not a manifest dep**)
|
||||
`request.env['fusion.api.service']` → **`KeyError` if `fusion_api` absent** (caught by
|
||||
fusion_schedule's bare `except` → fallback). 7 models: `fusion.api.service` (AbstractModel,
|
||||
broker), `fusion.api.{provider,key,consumer,access,usage,user.limit}` + `usage.daily`.
|
||||
Public methods fusion_schedule uses: `get_api_key(provider_type, consumer, feature)` →
|
||||
`api_service.py:394`; `call_openai(consumer, feature, messages, model)` → `:278`. **Raises
|
||||
`UserError` on 14 conditions** (no active provider `:62`; consumer disabled `:129`; access
|
||||
disabled `:141`; monthly/daily budget `:157/167`; rpm/rpd `:185/194`; user blocked/budget/rpd
|
||||
`:218/224/234`; no key `:81`; package missing `:280/335`; downstream API error `:319/381`) —
|
||||
**any** of these (or KeyError) triggers fusion_schedule's ICP fallback. `provider_type` enum:
|
||||
`openai, anthropic, google_maps, google_oauth, microsoft_oauth, twilio, custom`. Consumer
|
||||
auto-registers when `fusion_api.auto_detect_consumers` (default True).
|
||||
> ⚠ **`get_api_key` returns `key.api_key` (a `group_admin`-gated field) on a *non-sudo*
|
||||
> recordset (`api_service.py:407`).** For a portal/public request (non-admin/public user) this
|
||||
> likely raises `AccessError` → fusion_schedule's fallback fires **every time** → in practice
|
||||
> the maps key for portal/public callers comes from `fusion_claims.google_maps_api_key`, not the
|
||||
> broker. The broker path may effectively never succeed for raw-key access from the portal.
|
||||
|
||||
### 9.2 `portal_gradient` / `fc_gradient` / the tile target (`fusion_portal`)
|
||||
- `portal_gradient` computed in `fusion_portal/controllers/portal_main.py:81-87` from
|
||||
`fusion_claims.portal_gradient_{start,mid,end}` (defaults `#5ba848/#3a8fb7/#2e7aad`) — **only
|
||||
set for portal personas** (`is_authorizer/is_sales_rep_portal/is_client_portal/is_technician_portal`).
|
||||
fusion_schedule computes its **own identical copy** in `portal_schedule.py:33-36`, so its pages
|
||||
don't need the controller — only the **tile** does (via `fc_gradient`, set at
|
||||
`portal_templates.xml:10` = `portal_gradient or <default>`).
|
||||
- **Tile xpath fragility:** the tiles grid is `<div class="row g-3 mb-4">` (`portal_templates.xml:52-295`);
|
||||
the anchor is the `/my/funding-claims` card (`:277-294`). fusion_schedule's tile xpath
|
||||
(`portal_schedule_tile.xml:8`) needs **both** the `/my/funding-claims` `<a>` and the exact
|
||||
`row g-3 mb-4` class triple — change either and the tile **ParseErrors at install** of
|
||||
fusion_schedule.
|
||||
- **`tz` cookie** set by `fusion_portal/static/src/js/timezone_detect.js:25`: name `tz`, value
|
||||
raw IANA (`America/Toronto`), `path=/ max-age=31536000 SameSite=Lax`. Read at
|
||||
`portal_schedule.py:_resolve_timezone` (2nd priority after `user.tz`).
|
||||
|
||||
### 9.3 The borrowed `fusion_claims.*` params — ownership (defaults all match)
|
||||
| ICP key | owning field | file:line | default |
|
||||
|---|---|---|---|
|
||||
| `fusion_claims.portal_gradient_start/_mid/_end` | `fc_portal_gradient_*` | `fusion_claims/.../res_config_settings.py:461-474` | `#5ba848/#3a8fb7/#2e7aad` |
|
||||
| `fusion_claims.ai_api_key` | `fc_ai_api_key` | `fusion_claims/.../res_config_settings.py:355` | empty |
|
||||
| `fusion_claims.google_maps_api_key` | `fc_google_maps_api_key` | **`fusion_tasks`/.../res_config_settings.py:12-16** | empty |
|
||||
> ⚠ The maps key is **owned by `fusion_tasks`, not `fusion_claims`** (the `fusion_claims.*`
|
||||
> prefix is kept for data continuity). Grepping `fusion_claims/` for it finds nothing. Both
|
||||
> owners are transitive deps via fusion_portal, so the params are always present.
|
||||
|
||||
---
|
||||
|
||||
## 10. Sibling scheduling surfaces & how they interact with this module
|
||||
|
||||
**Baseline:** fusion_schedule is the **only** `calendar.event` extender; its `write/unlink`
|
||||
push to external is gated by `_skip_fc_sync()` (context `no_calendar_sync`/`dont_notify`) +
|
||||
presence of links, and `_cross_calendar_push` (cron) mirrors **unlinked Odoo-native** events
|
||||
(−1d…+90d, on the user's partner) to the **first** account **only if the user has >1 account**.
|
||||
|
||||
| Writer | what it creates | interaction with fusion_schedule |
|
||||
|---|---|---|
|
||||
| `fusion_claims` `schedule_assessment_wizard.py:186` | 1 `calendar.event`/manual schedule (assessor partner, optional email alarm), **plain create** | eligible for cron mirror; **later edits fire the synchronous `write()` push** |
|
||||
| `fusion_portal` `portal_assessment.py:1194` | 1 `calendar.event`/public booking (sales-rep partner; sets `accessibility.assessment.calendar_event_id`), **plain sudo create** | same as above |
|
||||
| `fusion_tasks` `technician_task.py:1572` (`_sync_calendar_event`) | **HIGH volume** — 1 event/task, re-synced on every schedule-field write/create; **writes with `silent_ctx` (`dont_notify=True`)** | synchronous push **suppressed**; external mirror deferred to the 5-min cron. **Protection hinges on `dont_notify` staying in `silent_ctx`** — drop it and every task edit becomes an inline Google/Outlook round-trip |
|
||||
|
||||
- **Reverse coupling:** `fusion_tasks` slot scheduler reads `calendar.event` for busy intervals
|
||||
(`technician_task.py:495-540`) and **excludes its own task-linked events**, so
|
||||
externally-synced calendar entries (pulled by fusion_schedule) correctly block technician
|
||||
availability.
|
||||
- **Repo sweep:** only these **4** modules touch `calendar.event`/appointments; **only
|
||||
fusion_schedule uses Enterprise `appointment.*`** (the others create raw `calendar.event`).
|
||||
`fusion_repairs` maintenance booking is still *planned*. Stale vendored copies of the task
|
||||
engine exist under `Entech Plating/` and `fusion_plating/` — **not** the canonical install
|
||||
path; flag for cleanup.
|
||||
- **Maps key consumers:** `fusion_tasks` travel-time (`_calculate_travel_time`) and
|
||||
`fusion_claims` `google_address_autocomplete.js` both read `fusion_claims.google_maps_api_key`
|
||||
(owned by fusion_tasks) — same key fusion_schedule falls back to.
|
||||
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
@@ -796,12 +795,12 @@ class PortalSchedule(CustomerPortal):
|
||||
if not event.exists() or partner not in event.partner_ids:
|
||||
return {'success': False, 'error': 'Event not found or access denied.'}
|
||||
|
||||
tz = self._get_user_timezone()
|
||||
# The slot datetime sent by the client is already UTC (the slot
|
||||
# generator emits UTC); parse it directly — do NOT re-localize, which
|
||||
# would double-shift the appointment by the user's UTC offset.
|
||||
try:
|
||||
start_naive = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
start_local = tz.localize(start_naive)
|
||||
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
except (ValueError, Exception) as e:
|
||||
start_utc = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, Exception):
|
||||
return {'success': False, 'error': 'Invalid date/time format.'}
|
||||
|
||||
duration = float(new_duration) if new_duration else event.duration
|
||||
@@ -883,12 +882,10 @@ class PortalSchedule(CustomerPortal):
|
||||
if not slot_datetime:
|
||||
return request.redirect('/schedule/manage/%s?error=Please+select+a+new+time+slot' % token)
|
||||
|
||||
tz = self._resolve_timezone(event.user_id)
|
||||
|
||||
# The slot datetime is already UTC (the slot generator emits UTC); parse
|
||||
# directly — do NOT re-localize (that double-shifts by the tz offset).
|
||||
try:
|
||||
start_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
start_local = tz.localize(start_naive)
|
||||
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
start_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, Exception):
|
||||
return request.redirect('/schedule/manage/%s?error=Invalid+time+slot' % token)
|
||||
|
||||
@@ -1499,12 +1496,10 @@ class PortalSchedule(CustomerPortal):
|
||||
'/schedule/%s?error=Name,+email,+and+time+slot+are+required' % slug
|
||||
)
|
||||
|
||||
tz = self._resolve_timezone(user)
|
||||
|
||||
# The slot datetime is already UTC (the slot generator emits UTC); parse
|
||||
# directly — do NOT re-localize (that double-shifts by the tz offset).
|
||||
try:
|
||||
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
start_dt_local = tz.localize(start_dt_naive)
|
||||
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
start_dt_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
except (ValueError, Exception) as e:
|
||||
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
|
||||
return request.redirect('/schedule/%s?error=Invalid+time+slot' % slug)
|
||||
@@ -1512,17 +1507,22 @@ class PortalSchedule(CustomerPortal):
|
||||
duration = float(slot_duration)
|
||||
stop_dt_utc = start_dt_utc + timedelta(hours=duration)
|
||||
|
||||
# Find or create partner for the visitor
|
||||
# Find or create a contact for the visitor. SECURITY: this is an
|
||||
# unauthenticated endpoint and visitor_email is attacker-controlled, so
|
||||
# never reuse/attach a partner that backs a login user (staff/internal),
|
||||
# and never write onto an existing contact. Reuse only a plain non-user
|
||||
# contact (avoids duplicates for genuine repeat visitors).
|
||||
Partner = request.env['res.partner'].sudo()
|
||||
partner = Partner.search([('email', '=ilike', visitor_email)], limit=1)
|
||||
partner = Partner.search([
|
||||
('email', '=ilike', visitor_email),
|
||||
('user_ids', '=', False),
|
||||
], limit=1)
|
||||
if not partner:
|
||||
partner = Partner.create({
|
||||
'name': visitor_name,
|
||||
'email': visitor_email,
|
||||
'phone': visitor_phone,
|
||||
'phone': visitor_phone or False,
|
||||
})
|
||||
elif visitor_phone and not partner.phone:
|
||||
partner.phone = visitor_phone
|
||||
|
||||
address_parts = [p for p in [visitor_street, visitor_city, visitor_province, visitor_postal] if p]
|
||||
location = ', '.join(address_parts)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
@@ -338,7 +338,17 @@ class FusionCalendarAccount(models.Model):
|
||||
updated = 0
|
||||
deleted = 0
|
||||
for event_data in all_events:
|
||||
result = self._process_google_event(event_data)
|
||||
# Per-row savepoint: one bad event must not abort the whole page
|
||||
# (which would leave sync_token unadvanced and re-fail every cron).
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
result = self._process_google_event(event_data)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Skipping Google event %s on account %s: %s",
|
||||
event_data.get('id'), self.id, e,
|
||||
)
|
||||
continue
|
||||
if result == 'created':
|
||||
created += 1
|
||||
elif result == 'updated':
|
||||
@@ -409,7 +419,15 @@ class FusionCalendarAccount(models.Model):
|
||||
stop_val = vals.get('stop') or vals.get('stop_date')
|
||||
if not (start_val and stop_val and vals.get('name')):
|
||||
return None
|
||||
domain = [('name', '=', vals['name']), ('active', 'in', [True, False])]
|
||||
# Scope to THIS account's owner so a same-titled, same-time event that
|
||||
# belongs to a DIFFERENT user is never merged in. Reuse only this
|
||||
# account's own pulled events, or the user's native (sourceless) events.
|
||||
domain = [
|
||||
('name', '=', vals['name']),
|
||||
('active', 'in', [True, False]),
|
||||
('partner_ids', 'in', [self.x_fc_user_id.partner_id.id]),
|
||||
('x_fc_source_account_id', 'in', [self.id, False]),
|
||||
]
|
||||
if vals.get('allday'):
|
||||
domain += [('start_date', '=', start_val), ('stop_date', '=', stop_val)]
|
||||
else:
|
||||
@@ -417,20 +435,20 @@ class FusionCalendarAccount(models.Model):
|
||||
return CalendarEvent.search(domain, limit=1)
|
||||
|
||||
def _upsert_event_link(self, EventLink, odoo_event_id, external_id, ical_uid):
|
||||
"""Create or update a link between an Odoo event and an external event.
|
||||
"""Create or update the link for this (account, external event).
|
||||
|
||||
If this account already has a link to the same Odoo event, update the
|
||||
external_id rather than creating a duplicate link row. Returns the
|
||||
link record.
|
||||
Branches on the table's real UNIQUE key (account, external_id) so it can
|
||||
never raise an IntegrityError; if the external event is already linked,
|
||||
re-point it at the given Odoo event. Returns the link record.
|
||||
"""
|
||||
existing = EventLink.search([
|
||||
('x_fc_account_id', '=', self.id),
|
||||
('x_fc_event_id', '=', odoo_event_id),
|
||||
('x_fc_external_id', '=', external_id),
|
||||
], limit=1)
|
||||
now = fields.Datetime.now()
|
||||
if existing:
|
||||
existing.write({
|
||||
'x_fc_external_id': external_id,
|
||||
'x_fc_event_id': odoo_event_id,
|
||||
'x_fc_universal_id': ical_uid or existing.x_fc_universal_id,
|
||||
'x_fc_last_synced': now,
|
||||
})
|
||||
@@ -481,7 +499,7 @@ class FusionCalendarAccount(models.Model):
|
||||
|
||||
existing_link = EventLink.search([
|
||||
('x_fc_universal_id', '=', ical_uid),
|
||||
('x_fc_universal_id', '!=', False),
|
||||
('x_fc_account_id.x_fc_user_id', '=', self.x_fc_user_id.id),
|
||||
], limit=1) if ical_uid else None
|
||||
|
||||
if existing_link and existing_link.x_fc_event_id:
|
||||
@@ -527,8 +545,8 @@ class FusionCalendarAccount(models.Model):
|
||||
start_dt = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
|
||||
end_dt = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
|
||||
# Convert to naive UTC for Odoo
|
||||
start_utc = start_dt.astimezone(tz=None).replace(tzinfo=None) if start_dt.tzinfo else start_dt
|
||||
end_utc = end_dt.astimezone(tz=None).replace(tzinfo=None) if end_dt.tzinfo else end_dt
|
||||
start_utc = start_dt.astimezone(timezone.utc).replace(tzinfo=None) if start_dt.tzinfo else start_dt
|
||||
end_utc = end_dt.astimezone(timezone.utc).replace(tzinfo=None) if end_dt.tzinfo else end_dt
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
vals = {
|
||||
@@ -567,10 +585,12 @@ class FusionCalendarAccount(models.Model):
|
||||
MICROSOFT_GRAPH_API, MICROSOFT_SELECT_FIELDS, start_dt, end_dt,
|
||||
)
|
||||
|
||||
all_events = []
|
||||
next_sync_token = self.x_fc_sync_token
|
||||
page_num = 0
|
||||
max_events = 5000 if self.x_fc_sync_token else 2000
|
||||
created = 0
|
||||
updated = 0
|
||||
deleted = 0
|
||||
processed = 0
|
||||
|
||||
while url:
|
||||
page_num += 1
|
||||
@@ -594,16 +614,28 @@ class FusionCalendarAccount(models.Model):
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Process each page as it arrives — no unbounded accumulation and no
|
||||
# event cap that would silently drop everything past the limit. Each
|
||||
# event gets its own savepoint so one bad row can't abort the page.
|
||||
page_events = data.get('value', [])
|
||||
all_events.extend(page_events)
|
||||
_logger.warning("MS sync account %s page %d: %d events (total %d)", self.id, page_num, len(page_events), len(all_events))
|
||||
|
||||
if len(all_events) >= max_events:
|
||||
_logger.warning(
|
||||
"MS sync account %s: hit event limit (%d/%d), stopping fetch",
|
||||
self.id, len(all_events), max_events,
|
||||
)
|
||||
break
|
||||
for event_data in page_events:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
result = self._process_microsoft_event(event_data)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Skipping MS event %s on account %s: %s",
|
||||
event_data.get('id'), self.id, e,
|
||||
)
|
||||
continue
|
||||
if result == 'created':
|
||||
created += 1
|
||||
elif result == 'updated':
|
||||
updated += 1
|
||||
elif result == 'deleted':
|
||||
deleted += 1
|
||||
processed += 1
|
||||
_logger.warning("MS sync account %s page %d: %d events (processed %d total)", self.id, page_num, len(page_events), processed)
|
||||
|
||||
url = data.get('@odata.nextLink')
|
||||
if not url:
|
||||
@@ -611,21 +643,6 @@ class FusionCalendarAccount(models.Model):
|
||||
if '$deltatoken=' in delta_link:
|
||||
next_sync_token = delta_link.split('$deltatoken=')[-1]
|
||||
|
||||
_logger.warning("MS sync account %s: processing %d events...", self.id, len(all_events))
|
||||
created = 0
|
||||
updated = 0
|
||||
deleted = 0
|
||||
for i, event_data in enumerate(all_events):
|
||||
result = self._process_microsoft_event(event_data)
|
||||
if result == 'created':
|
||||
created += 1
|
||||
elif result == 'updated':
|
||||
updated += 1
|
||||
elif result == 'deleted':
|
||||
deleted += 1
|
||||
if (i + 1) % 25 == 0:
|
||||
_logger.warning("MS sync account %s: processed %d/%d events", self.id, i + 1, len(all_events))
|
||||
|
||||
self.sudo().write({
|
||||
'x_fc_sync_token': next_sync_token,
|
||||
'x_fc_last_sync': fields.Datetime.now(),
|
||||
@@ -714,7 +731,7 @@ class FusionCalendarAccount(models.Model):
|
||||
|
||||
existing_link = EventLink.search([
|
||||
('x_fc_universal_id', '=', ical_uid),
|
||||
('x_fc_universal_id', '!=', False),
|
||||
('x_fc_account_id.x_fc_user_id', '=', self.x_fc_user_id.id),
|
||||
], limit=1) if ical_uid else None
|
||||
|
||||
if existing_link and existing_link.x_fc_event_id:
|
||||
|
||||
Reference in New Issue
Block a user