Compare commits

...

3 Commits

Author SHA1 Message Date
gsinghpal
1f818096db fix(fusion_helpdesk_central): findings + summary actually span full width
Previous fix (col=1 on the group) didn't work — Odoo still rendered
the group's string as a left-column label inside the form sheet's
flow, so the textarea got pushed into a narrow right column. The
summary field looked entirely missing because its content split
between the button row (on the right) and the textarea (collapsed
nowhere visible).

Right idiom (lifted from Odoo's own mail.compose.message wizard):
WIDE textareas live directly at the form level, not inside <group>.
Section titles use <separator string="…"> which renders as a
horizontal divider with the label above. The textarea then takes
the full sheet width naturally.

Same pattern applied to bulk mode for consistency.

Also moved Personal Note into the top compact group with Owner /
Owner Email since it's a one-line input that belongs with the
header info, not pretending to be a wide section.

Bumps fusion_helpdesk_central to 19.0.2.3.2.
2026-05-27 14:17:23 -04:00
gsinghpal
bb873e8a7a feat(billing): importer Test Connection guard + operator runbook
Add action_test_connection — a read-only connectivity/schema check that
reports source row counts and imports nothing, the safe first step before
a dry-run. Wire a "Test Connection" button on the wizard. Document the
end-to-end run in the README: least-privilege read-only DB role SQL, the
fusion_billing.nexacloud_dsn system parameter (libpq DSN = NexaCloud's
URL minus +asyncpg), and the Test → dry-run → real-run flow. Refresh the
stale SCAFFOLD status. 53/53 green on odoo-trial.
2026-05-27 14:16:32 -04:00
gsinghpal
d4ef4d55e0 fix(fusion_repairs): wrap Wysiwyg content with markup() so HTML renders, not escapes
User reported the rich text editor showing raw HTML tags as literal text
instead of rendering them as formatted prose. Root cause: Odoo's Editor
delegates content insertion to setElementContent() (web/core/utils/html.js),
which only takes the innerHTML branch when the content was flagged as safe
markup via owl's markup() helper. Plain strings fall through to the
textContent branch, which is what the user was seeing:

    <p>Ask the client if the stairlift has power. Check:</p> <ul> <li>...

instead of the rendered paragraph + list.

The canonical html_field.js in @html_editor wraps its value with markup()
before passing it to the Wysiwyg config; I missed that detail.

FIX
- import markup from @odoo/owl
- in wysiwygConfig getter, wrap the saved content_html string with
  markup() before assigning to config.content
- pass markup("") for empty content (avoids editor confusion with falsy)
- load-bearing comment to keep future refactors from re-introducing the bug

VERIFIED
- upgrade clean
- 7 stale asset bundles flushed, container restarted, login serves 200
- new bundle 014fee9 renders 10029808 bytes
- node --check PARSE_OK
- compiled bundle contains: content:rawHtml?markup(rawHtml):markup("")
  which is exactly the markup-wrapped path the Editor wants

Bumped to 19.0.2.2.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:55:20 -04:00
8 changed files with 123 additions and 56 deletions

View File

@@ -7,9 +7,9 @@ home-grown Stripe billing into one customer ledger and one accounting system.
> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md)
>
> **Status:** **SCAFFOLD.** Models + security + the API auth shell are in place and the
> module installs. The usage engine, full inbound API, and webhook processor are stubs
> to be implemented from the writing-plans output.
> **Status:** Core engine (sub-project #1) and the **NexaCloud importer (sub-project #2a)**
> are implemented and tested on odoo-trial Enterprise. 2b (usage wiring), 2c (control loop),
> and 2d (reconciliation) are pending.
## Why this module is small
@@ -59,6 +59,42 @@ cost into margin reporting; reuse its daily-rollup aggregation pattern.
`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`.
## Running the NexaCloud import (2a)
Exposed as **Fusion Billing → Import from NexaCloud** (a wizard). It runs entirely
read-only against NexaCloud, and everything it creates in Odoo is shadow-safe (draft
subscriptions, no payment token, charges with NULL `plan_id`) so it cannot charge or post
during the dual-run.
**1. Create a least-privilege read-only role in the NexaCloud Postgres (LXC 201):**
```sql
CREATE ROLE odoo_billing_ro WITH LOGIN PASSWORD '<choose-a-strong-password>';
GRANT CONNECT ON DATABASE nexacloud TO odoo_billing_ro;
GRANT USAGE ON SCHEMA public TO odoo_billing_ro;
GRANT SELECT ON users, plans, subscriptions, deployments TO odoo_billing_ro;
```
**2. Point Odoo at it** via the system parameter (Settings → Technical → System Parameters,
or odoo-shell). psycopg2 wants a **libpq DSN** — i.e. NexaCloud's SQLAlchemy URL *without*
`+asyncpg`:
```
key: fusion_billing.nexacloud_dsn
value: postgresql://odoo_billing_ro:<password>@<lxc201-host>:5432/nexacloud
```
(Odoo on nexa / VM 315 must have a network route to the LXC 201 Postgres port.)
**3. Validate → dry-run → run for real:**
- **Test Connection** — confirms reachability + schema and reports row counts; imports nothing.
- **Run Import** with **Dry run** ticked — computes the whole import inside a rolled-back
savepoint and reports created / updated / **skipped** / **failed** counts; writes nothing.
A red/amber banner flags any failures — investigate them before proceeding.
- Untick **Dry run** and **Run Import** to persist the shadow copy. Re-running is safe and
idempotent (upserts, never duplicates).
## Local dev
```bash

View File

@@ -243,3 +243,9 @@ class TestImporterReadGuard(TransactionCase):
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
with self.assertRaises(UserError):
wiz._read_nexacloud_rows()
def test_test_connection_guards_missing_dsn(self):
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
with self.assertRaises(UserError):
wiz.action_test_connection()

View File

@@ -19,6 +19,8 @@
<field name="result_summary" nolabel="1" widget="text"/>
</group>
<footer>
<button name="action_test_connection" type="object"
string="Test Connection" class="btn-secondary"/>
<button name="action_run_import" type="object" string="Run Import"
class="btn-primary"/>
<button string="Close" class="btn-secondary" special="cancel"/>

View File

@@ -60,6 +60,22 @@ class FusionBillingImportWizard(models.TransientModel):
"target": "new",
}
def action_test_connection(self):
"""Read-only connectivity + schema check: connect, read the source tables, and
report row counts WITHOUT importing anything. The safe first step before a
dry-run — surfaces a bad DSN, no network route, or a schema drift up front."""
self.ensure_one()
data = self._read_nexacloud_rows()
msg = "Connected. Read %s user(s), %s plan(s), %s subscription(s)." % (
len(data.get("users", [])), len(data.get("plans", [])),
len(data.get("subscriptions", [])))
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {"title": "NexaCloud connection OK", "message": msg,
"type": "success", "sticky": False},
}
# ----- read side (the ONLY code that touches NexaCloud) ------------------
def _read_nexacloud_rows(self):
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in

View File

@@ -3,7 +3,7 @@
# License OPL-1
{
'name': 'Fusion Helpdesk Central — Client API Keys',
'version': '19.0.2.3.1',
'version': '19.0.2.3.2',
'category': 'Productivity',
'summary': 'Admin UI on the central Odoo for issuing per-client API '
'keys used by fusion_helpdesk client deployments.',

View File

@@ -18,11 +18,16 @@
<field name="mode" invisible="1"/>
<field name="ticket_id" invisible="1"/>
<!--
Compact "who + note" header in a normal 2-col group.
Owner is read-only display, Personal Note is short.
-->
<group>
<group>
<field name="owner_name_display" string="Owner"/>
<field name="owner_email_display" string="Owner Email" widget="email"/>
</group>
<field name="owner_name_display" string="Owner" readonly="1"/>
<field name="owner_email_display" string="Owner Email"
widget="email" readonly="1"/>
<field name="personal_note"
placeholder="One-line note that appears above the summary in the email…"/>
</group>
<div class="alert alert-warning" role="alert"
@@ -34,63 +39,60 @@
</div>
<!--
Single mode. The user fills findings first, clicks
Generate Summary, reviews the AI output, edits if needed,
then Sends. We deliberately don't auto-fire OpenAI on
open — the AI needs the engineer's analysis to be useful.
Wide textareas live DIRECTLY at the form level (not inside
a <group>) so they take the full sheet width — the same
pattern Odoo's mail.compose.message wizard uses for the
`body` HTML editor. Section titles use <separator>, which
renders as a horizontal divider with a label above the
next field. This is the only Odoo 19 idiom that reliably
gives a full-width textarea inside a wizard dialog.
-->
<group invisible="mode != 'single'">
<field name="personal_note"
placeholder="One-line note that appears above the summary in the email…"/>
</group>
<!--
`col="1"` on these single-column groups makes the
enclosed field span the full group width. The default
`col="2"` reserves a label column even with nolabel=1,
which is what was squeezing the textareas into half
width before. Findings + summary are wide-form inputs
that deserve all the real estate the dialog has.
-->
<group invisible="mode != 'single'" string="1. Your Findings" col="1">
<field name="findings" nolabel="1"
widget="text"
placeholder="What did your investigation surface? Scope, limitations, recommended approach, effort, risks — anything the owner needs to know that the original reporter wouldn't have."/>
</group>
<group invisible="mode != 'single'" string="2. Summary to Send" col="1">
<div class="d-flex align-items-center">
<button name="action_generate_summary" type="object"
string="Generate Summary from Findings"
icon="fa-magic"
class="btn-secondary"/>
<span class="text-muted ms-2">
← click after writing your findings, then review &amp; edit below
</span>
</div>
<field name="ai_summary" nolabel="1"
widget="text"
placeholder="Click Generate Summary above, or write the brief yourself. The owner reads this first."/>
</group>
<separator string="1. Your Findings"
invisible="mode != 'single'"/>
<field name="findings"
invisible="mode != 'single'"
nolabel="1"
widget="text"
placeholder="What did your investigation surface? Scope, limitations, recommended approach, effort, risks — anything the owner needs to know that the original reporter wouldn't have."/>
<separator string="2. Summary to Send"
invisible="mode != 'single'"/>
<div class="d-flex align-items-center mb-2"
invisible="mode != 'single'">
<button name="action_generate_summary" type="object"
string="Generate Summary from Findings"
icon="fa-magic"
class="btn-secondary"/>
<span class="text-muted ms-2">
← click after writing your findings, then review &amp; edit below
</span>
</div>
<field name="ai_summary"
invisible="mode != 'single'"
nolabel="1"
widget="text"
placeholder="Click Generate Summary above, or write the brief yourself. The owner reads this first."/>
<!--
Bulk mode: same two-step flow per ticket. Findings +
Summary editable inline; a single Generate All button
fans out OpenAI in parallel using each line's findings.
Bulk mode: same two-step flow, per-line. The list view
is wide by default because list views fill the form
sheet — no special layout work needed.
-->
<group invisible="mode != 'bulk'">
<field name="personal_note"
placeholder="One-line note that appears once at the top of the combined email…"/>
</group>
<div class="o_row" colspan="2"
<separator string="Per-Ticket Findings &amp; Summaries"
invisible="mode != 'bulk'"/>
<div class="d-flex align-items-center mb-2"
invisible="mode != 'bulk'">
<button name="action_generate_all_summaries" type="object"
string="Generate All Summaries"
icon="fa-magic"
class="btn-secondary"/>
<span class="o_form_label text-muted ms-2">
<span class="text-muted ms-2">
← fill in findings per row first, then click to regenerate all summaries in parallel
</span>
</div>
<field name="line_ids" invisible="mode != 'bulk'" nolabel="1">
<field name="line_ids"
invisible="mode != 'bulk'"
nolabel="1">
<list editable="bottom" create="0" delete="0">
<field name="ticket_id" readonly="1"/>
<field name="ticket_name" readonly="1"/>

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Repairs',
'version': '19.0.2.2.3',
'version': '19.0.2.2.4',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """

View File

@@ -19,7 +19,7 @@
* the source of truth for the designer view and the node/edge tables are
* the source of truth for the runtime traversal.
*/
import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
import { Component, markup, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
@@ -106,8 +106,13 @@ export class FlowchartDesigner extends Component {
*/
get wysiwygConfig() {
const meta = this.selectedMeta;
// CRITICAL: wrap the HTML string with markup() so the editor's
// setElementContent() takes the innerHTML branch instead of the
// textContent branch - otherwise the editor renders our tags as
// literal text instead of formatted prose / lists.
const rawHtml = meta?.content_html || "";
return {
content: meta?.content_html || "",
content: rawHtml ? markup(rawHtml) : markup(""),
Plugins: MAIN_PLUGINS,
// onChange fires after each edit step - read the current HTML
// from the editor and persist it to selectedMeta + dirty flag.