Files
Odoo-Modules/fusion_schedule/CLAUDE.md
gsinghpal ba7c028c30 CHANGES
2026-06-04 09:49:51 -04:00

50 KiB
Raw Blame History

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 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_repairsit 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__.pydepends)

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.

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 §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 xpaths 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.

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.

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.xmlir_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_openaifusion_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 MonSun 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.tztz 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.

  • 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.jspatch(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.xmlportal_schedule_page, portal_schedule_book (both portal.portal_layout).
  • views/public_booking.xmlpublic_booking_page, public_manage_page (both website.layout; carry their own inline <script> — see §9.1).
  • views/portal_schedule_tile.xmlportal_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).
    • 747c8142fusion_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 (:801803)
    • public_book_submit (:15051507)
    • public_manage_reschedule (:889891)

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

  1. "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

  1. 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.
  2. 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.
  3. 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 (810) is the most serious — it corrupts data across users:

  1. 🔴 _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.
  2. 🔴 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.
  3. 🔴 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).
  4. 🔴 MS delta page-cap stalls large calendars. _sync_pull_microsoft caps at 2000/5000 and breaks 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.

  5. 🟡 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.
  6. 🟡 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.
  7. 🟡 sync_current_user commits mid-loop (:1097) — non-atomic inside an interactive RPC; reports {success: False} after already persisting earlier accounts.
  8. 🟡 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:

  1. 🟡 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.
  2. 🟡 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.)
  3. 🟡 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.
  4. 🟠 _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 :766authenticated 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