50 KiB
fusion_schedule — Claude Code Instructions
Module-level guide. The repo-wide Odoo 19 rules in
K:\Github\Odoo-Modules\CLAUDE.md(and the globalK:\Github\CLAUDE.md) still apply — this file only adds what is specific tofusion_schedule. Read both.Companion docs:
CODE_MAP.mdis 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 Supabasefusionapps.issuesunder 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:
- 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.eventand pushes Odoo-native events out, so the user has one merged calendar and is "busy on one → blocked on all". - 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. - 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_calendarsync. 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. Usesappointment.type,appointment.invite, andappointment_type._prepare_calendar_event_values(...)to build booking events.calendar— the core model everything revolves around (calendar.eventis inherited).google_account/microsoft_account— base OAuth plumbing. Note: the module rolls its own OAuth flow (it does not reusegoogle_calendar/microsoft_calendarsync). It only borrows their stored client-id ICP params as a fallback (see §10).fusion_portal— the onlyfusion_*hard dependency. This is what transitively pulls in the whole claims stack:fusion_portal → fusion_claims(+fusion_tasks,fusion_loaners_management,knowledge). Sofusion_claimsis 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 independs; every call is wrapped intry/exceptand falls back tofusion_claims.*ICP params, then degrades gracefully. The module still runs iffusion_apiis absent.
3.3 Reverse dependencies
- Nothing depends on
fusion_schedule. It is a leaf/top module. The only mention elsewhere isfusion_repairs/__manifest__.pywhich 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§9): (1)get_api_keyreturns thegroup_admin-gatedkey.api_keyon a non-sudo recordset, so from a portal/public request it likely raisesAccessErrorand the ICP fallback fires every time — for portal callersfusion_claims.google_maps_api_keyis effectively the real source, not the broker. (2) That maps-key param is actually owned byfusion_tasks(res_config_settings.py:12), notfusion_claims, despite thefusion_claims.*prefix — greppingfusion_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_gradientorigin: set infusion_portal/views/portal_templates.xmlasportal_gradient or <default green/blue>, whereportal_gradientis computed by fusion_portal's home controller from the samefusion_claims.portal_gradient_*params (§4.2). The tile falls back to the literal default iffc_gradientis 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 plaincalendar.eventfor an ADP assessment from asale.order(optional 1-day email alarm). No sync, no portal, no travel logic.technician_taskrouting — push notifications + travel time using the samefusion_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_*— dedicatedfusion_schedule_*ICP param → nativegoogle_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 markederrorand 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 GraphcalendarView/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_valsand 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) andx_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 hasno_calendar_syncordont_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(UNIQUEconstraint),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
_get_valid_token()(refresh if needed).- Fetch pages (sync-token delta when available, else the ±window).
- 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_eventby name+start+stop (incl. archived) → reuse + relink; - else create a new
calendar.event(owner partner attached) + new link.
- existing link for
- 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_addressuses the Geocoding API (regionca). - Travel blocks
_create_travel_blocks(event, staff_user)— after a booking, looks at the prev/next located appointments that day and insertsTravel to …placeholder events (x_fc_is_travel_block=True,show_as=busy) sized tomax(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_keydirect-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_pagecomputesshare_url = appointment.invite.book_url(native appointment share, looked up bystaff_user_ids) andpublic_booking_url = <base>/schedule/<slug>. Onlypublic_booking_urlis actually rendered (the "Share Booking Link" card/button).share_urlis passed to the template but never used — and the only seededappointment.invite(default_appointment_invite) has emptyappointment_type_ids/no staff, so it would be blank anyway. The slug link is the real share mechanism.- There is no
_prepare_home_portal_valuesoverride, so/my/schedulehas 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-inheritscalendar.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.cssstatic/src/js/portal_schedule_booking.js— booking form: sets thetzcookie, 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/scheduledashboard: reusablefusionConfirmmodal +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(bothportal.portal_layout).views/public_booking.xml→public_booking_page,public_manage_page(bothwebsite.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 inportal_schedule_booking.js, they coordinate viawindow._googleMapsReady/window._scheduleAutocompleteInit. Maps only loads when agoogle_maps_api_keyresolved (§4.2/§4.3) — no key ⇒ no autocomplete, fields still work manually. - Dead toast markup.
portal_schedule.xmlships a Bootstrap#fusionToast/#fusionToastMessageelement, butportal_schedule_accounts.jsdefines its ownfusionToast()that builds a fresh#fusionToastLivenode and ignores the template one. Don't wire new code to#fusionToast; call the JSfusionToast(msg, type)helper. - CSS (
portal_schedule.css) is tiny: collapse-chevron rotation, a.min-width-0truncation helper, and mobile sizing for slot buttons / tables / modals. No theming — colours come from the inlineportal_gradientstyles 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)
- The silent-context flags are load-bearing. Any time you create/write/unlink a
calendar.eventduring sync or travel-block creation, pass_silent_ctx()(or at leastno_calendar_sync=True, dont_notify=True). Otherwise thecalendar.eventwrite/unlinkoverrides 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. - MS delta
@removedreason matters.@removedwith reason'deleted'(orisCancelled) → archive + unlink.@removedwith 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 thef1cea2fbbug fix ("stop archiving valid events on @removed=changed"). Don't regress it. cr.commit()/cr.rollback()in the cron will raise insideTransactionCase. 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 towith self.env.cr.savepoint():per iteration instead of commit/rollback, or the test cursor will break.- Declarative SQL objects only (rule #9): this module already uses
models.Constraint(...)for the unique constraints — keep that style, never_sql_constraintsorinit(). google_account/microsoft_account≠ native calendar sync. Don't "simplify" by reusinggoogle_calendar/microsoft_calendarsync — this module intentionally owns its OAuth + sync and hides the native UI. The native client-id params are only a credential fallback.- Public endpoints.
/schedule/<slug>and/schedule/manage/<token>areauth='public'. The manage token issecrets.token_hex(16)(32 chars) and_get_event_by_tokenenforceslen == 32. Public booking requires bothx_fc_booking_enabled=Trueand the user having anappointment.typewith them as staff. Keep CSRF on the POST forms; the slot JSON-RPC endpoints arecsrf=Falseby design. data/appointment_invite_data.xmlisnoupdate=1and shipsdefault_appointment_invitewith emptyappointment_type_ids— the generic/book/book-appointmentshare link won't resolve to a real type until configured. The/my/schedulepage separately resolves anappointment.invitebystaff_user_ids.data/mail_template_data.xmlis NOTnoupdate— the booking confirmation template (fusion_schedule_booking_confirmation, oncalendar.event) reloads on every-u. It renders the manage link fromcompany.website or get_base_url().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 itsGRAPH_REPORT.mdas authoritative (it's a heuristic code-graph, ~87% extracted).- Soft-dependency discipline. Never assume
fusion_apiis installed — keep thetry/except+ ICP fallback pattern in_get_maps_api_key/_call_ai. Addingfusion_api/fusion_claimstodependswould change the install graph; only do it deliberately. - Public booking does NOT re-validate the slot.
schedule_book_submit(authenticated) re-runs_generate_available_slotsand rejects a slot that's no longer free;public_book_submitdoes not — it trusts the postedslot_datetime. Two visitors hitting the same public slot can double-book. If you tighten this, add the same re-validation to the public path. - The two booking flows diverge (§9.1): authenticated bookings are real
appointmentevents (_prepare_calendar_event_values); public bookings are rawcalendar.eventrows. Reporting/automation that assumes every booking is anappointment.typebooking will miss public ones. Client-side changes must be made twice (asset file and the inline script inpublic_booking.xml). public_booking_pagereferencestodaybut the controller never passes it. The template hast-att-min="today"on the date picker, yetPortalSchedule.public_booking_page()'s values dict omitstoday. Either the website render context happens to supply it or theminis silently empty (no past-date guard on the public picker). Verify / fix by passingtodayfrom the controller if you touch this page. (The authenticated book page correctly usesnow.strftime('%Y-%m-%d').)- 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, threadportal_gradientthroughpublic_booking_page/public_manage_pagevalues.
15. Deployment & history
- Built in Cursor; now maintained in Claude Code.
- Lives wherever
fusion_portallives (the authorizer/sales-rep portal — the Westin Enterprise environment per the repoCLAUDE.mdWestin 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_portalrenamed fromfusion_authorizer_portal(this module'sdepends/tileinherit_idalready reference the new namefusion_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
-
Timezone double-conversion on 3 of the 4 booking write-paths. The slot's hidden
datetimeis 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, notzcookie /user.tz), which is why it can pass casual testing — but with thetz-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 likeschedule_book_submit(drop thelocalize/astimezone). - ✅
-
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). -
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
- "Busy on one, blocked on all" is enforced at portal-booking time, not by syncing
events between external calendars.
_cross_calendar_pushskips 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'scalendar.eventrows (everything pulled from every calendar) when computing free slots — so booking through/my/schedulerespects every connected calendar. Booking directly in Google will not block Outlook._cross_calendar_pushonly 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
- Slug generation can block user creation.
res.users.createsetsx_fc_schedule_slugfor every new user, guarded byUNIQUE(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. - Unthrottled public booking.
/schedule/<slug>/bookcreates ares.partner, acalendar.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. - Synchronous external HTTP inside
calendar.event.write()/unlink(). Because fusion_schedule is the solecalendar.eventextender (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:
- 🔴
_find_existing_eventmerges events across users + resurrects archived ones.fusion_calendar_account.py:401-417dedups 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'scalendar.eventand links B's account onto it; also reactivates a deliberately-archived event. Runs as root in cron → crosses companies. Fix: scope topartner_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. - 🔴 iCalUID cross-link is unscoped.
fusion_calendar_account.py:482-489(Google) /715-724(MS) matchx_fc_universal_idacross 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 tox_fc_account_id.x_fc_user_id = self.x_fc_user_id.id. - 🔴 No per-row isolation in the sync loop.
_sync_pull_google/_microsoftloop_process_*_eventwith no savepoint and writesync_tokenafter the loop. One row exception (e.g. an IntegrityError —_upsert_event_linkbranches on(account,event_id)at:419-445but the UNIQUE is(account,external_id)atfusion_calendar_event_link.py:32) rolls back the whole page and never advancessync_token→ deterministic errors wedge the account forever. Fix:with self.env.cr.savepoint():per row; branch the upsert on(account, external_id). - 🔴 MS delta page-cap stalls large calendars.
_sync_pull_microsoftcaps at 2000/5000 andbreaks without the@odata.deltaLink(:601-606), writing back the old token → a2000-event window re-fetches the same 2000 forever and never delivers the rest. The 410/
fullSyncRequiredrecursion (:318-321,:588-591) has no depth guard. - 🟡 Public booking mutates/attaches an existing partner by email.
public_book_submit(portal_schedule.py:1516-1525) doesPartner.search([('email','=ilike', visitor_email)])then writesphoneonto 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. - 🟡 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 +Refererto 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, addReferrer-Policy: no-referrer, re-validate, throttle. - 🟡
sync_current_usercommits mid-loop (:1097) — non-atomic inside an interactive RPC; reports{success: False}after already persisting earlier accounts. - 🟡 Dead imports trip pyflakes:
import secrets(calendar_event.py:4) andimport hashlib(controllers/portal_schedule.py:4) are unused. (res_users.pyis fine — it usesuuid.)
Refinement to #4:
_cross_calendar_pushis also gated bylen(user_accounts) > 1(:1149), so single-account users never get their Odoo-native events pushed out at all, and thestart >= now-1dfilter 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:
- 🟡
todayundefined on the public booking page.public_booking.xml:79(t-att-min="today") butpublic_booking_page(:1418-1426) never passestoday(the authenticated page correctly passesnow). 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. - 🟡 Confirmation email renders UTC times + wrong language.
mail_template_data.xmlt-out object.start/stopwith thedatetimewidget renders in the renderer's tz (UTC onforce_sendfrom a portal request) → email shows UTC, not the client's local time. Andlang = {{ object.partner_ids[:1].lang }}picks the first partner = the staff user, not the client. (Mail body is otherwise rule-17-safe — nourl_encode/undefined names;res.company.website+get_base_url()resolve.) - 🟡 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 Placesfields:[...]filter → Google all-fields billing tier. - 🟠
_prepare_calendar_event_valuessignature unverified.portal_schedule.py:717-730calls this private Enterprise method (signature shifted across 16→19). A mismatched kwarg raisesTypeError, swallowed by theexceptat: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) againstcalendar.AttendeeCalendarController. It resolves on the deployed version (else the entireweb.assets_backendbundle would be dead), but a future Odoo restructure of that template would brick the bundle. Prefer a stabler selector when next touched. - The
appointment.inviteseed (appointment_invite_data.xml:8) has emptyappointment_type_idsand nostaff_user_ids, soschedule_page'sshare_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 ofx_fc_schedule_slug / _booking_enabled / _work_start / _work_end / _break_start / _travel_buffer / _home_address / _home_latappears in any other module. calendar.eventis inherited byfusion_schedulealone (whole repo). Itswrite/unlinkoverrides 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_portalonly overrides_generate_tutorial_articles/portal.wizard.user;fusion_tasksaddsx_fc_is_field_staff / x_fc_start_address / x_fc_tech_sync_id(nocreate, no overlap). So the@api.model_create_multi create()slug hook chains cleanly viasuper().
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