Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
spec:
- tests/01-spp-starter-spmis.spec.ts
- tests/02-spp-starter-farmer-registry.spec.ts
- tests/03-spp-dci-compliance.spec.ts

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -57,6 +58,7 @@ jobs:
case "${{ matrix.spec }}" in
*spmis*) NAME="SP-MIS" ;;
*farmer*) NAME="Farmer Registry" ;;
*dci*) NAME="DCI Compliance" ;;
*) NAME="${{ matrix.spec }}" ;;
esac

Expand Down Expand Up @@ -109,7 +111,7 @@ jobs:
FIELDS=$(jq -n \
--arg repo "${{ github.repository }}" \
--arg ref "${{ github.ref_name }}" \
--arg specs "tests/01-spp-starter-spmis.spec.ts, tests/02-spp-starter-farmer-registry.spec.ts" \
--arg specs "tests/01-spp-starter-spmis.spec.ts, tests/02-spp-starter-farmer-registry.spec.ts, tests/03-spp-dci-compliance.spec.ts" \
'[{"name": "Repository", "value": $repo, "inline": true}, {"name": "Ref", "value": $ref, "inline": true}, {"name": "Spec files", "value": $specs, "inline": false}]')

for f in statuses/*.json; do
Expand Down
11 changes: 11 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,17 @@ repos:
# Exclude: scripts, tests, data, demo, and third-party modules
exclude: ^scripts/|/tests/|/data/|/demo/|^(fastapi|job_worker|base_user_role|extendable|extendable_fastapi|endpoint_route_handler)/
pass_filenames: true
- id: openspp-check-odoo19-js
name: "OpenSPP: Odoo 19 compatibility (JavaScript)"
description: "Check for useService() calls on web services removed in Odoo 19"
entry: python scripts/lint/check_odoo19.py --js
language: python
additional_dependencies:
- PyYAML
types: [javascript]
# Exclude: scripts, e2e, vendored libs, tests, and third-party modules
exclude: ^scripts/|^e2e/|/static/lib/|/static/tests/|^(fastapi|job_worker|base_user_role|extendable|extendable_fastapi|endpoint_route_handler)/
pass_filenames: true
# API authentication enforcement
- id: openspp-check-api-auth
name: "OpenSPP: API endpoint authentication"
Expand Down
9 changes: 5 additions & 4 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ Tests run sequentially (1 worker). Each test file resets the database before run

## Test files

| File | Module | Description |
| ---------------------------------------- | ----------------------------- | --------------------------------------------------------------- |
| `01-spp-starter-spmis.spec.ts` | `spp_starter_sp_mis` | Installs OpenSPP Starter SP-MIS and verifies nav menus |
| `02-spp-starter-farmer-registry.spec.ts` | `spp_starter_farmer_registry` | Installs OpenSPP Starter Farmer Registry and verifies nav menus |
| File | Module | Description |
| ---------------------------------------- | ----------------------------- | ---------------------------------------------------------------------- |
| `01-spp-starter-spmis.spec.ts` | `spp_starter_sp_mis` | Installs OpenSPP Starter SP-MIS and verifies nav menus |
| `02-spp-starter-farmer-registry.spec.ts` | `spp_starter_farmer_registry` | Installs OpenSPP Starter Farmer Registry and verifies nav menus |
| `03-spp-dci-compliance.spec.ts` | `spp_dci_compliance` | Installs DCI Compliance and verifies the security-warning systray item |

## How each test works

Expand Down
185 changes: 185 additions & 0 deletions e2e/tests/03-spp-dci-compliance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// OpenSPP DCI Compliance — end-to-end test suite
//
// What this tests:
// 01 - Logs in as admin and installs the spp_dci_compliance module via the Apps menu
// 02 - Reloads the backend and confirms the webclient renders cleanly (navbar visible,
// no uncaught page error, no client error dialog) and that the DCI
// security-warning systray item shows a badge of 3, naming the three insecure
// dci.* settings the module's post-install hook turns on (unsigned requests,
// HTTP callbacks, internal callback IPs)
// 03 - Turns dci.allow_unsigned_requests back off in the System Parameters list,
// reloads, and confirms the badge drops to 2 and that setting is no longer
// listed in the dropdown
//
// The systray component runs for every backend user on every page load, so it is the
// one place a broken frontend service lookup (useService on a service Odoo 19 removed)
// takes the whole webclient down. Nothing else in CI mounts it — module tests run
// without a browser — which is why this spec exists.
//
// All tests run in order and share a single browser session (test.describe.serial).
// A fresh Docker stack is spun up in beforeAll so every run starts from a clean database.

import {test, expect, Page} from "@playwright/test";
import {resetStack} from "./helpers";

const SYSTRAY_ITEM = ".o_dci_security_warning";
// Odoo's error service renders every uncaught client error into one of these.
const ERROR_DIALOG =
".o_error_dialog, .modal:has-text('Odoo Client Error'), .modal:has-text('Odoo Error')";

async function login(page: Page) {
await page.goto("/web/login");
await page.getByRole("textbox", {name: "Email"}).fill("admin");
await page.getByRole("textbox", {name: "Password"}).fill("admin");
await page.getByRole("button", {name: "Log in"}).click();
await expect(page.locator(".o_main_navbar")).toBeVisible({timeout: 30_000});
}

async function installApp(page: Page, technicalName: string) {
console.log("✅ Clicking Apps menuitem");
await page.getByRole("menuitem", {name: "Apps"}).click();
await page.waitForLoadState("domcontentloaded");
console.log("✅ Apps page loaded");

// Remove all preset filter chips (there are two by default); spp_dci_compliance is
// not flagged as an application, so the default "Apps" filter would hide it.
await page.getByRole("button", {name: "Remove"}).click();
await page.waitForLoadState("domcontentloaded");
console.log("✅ Filter cleared, page settled");

// Search by technical module name
await page.getByRole("searchbox", {name: "Search..."}).fill(technicalName);
await page.getByRole("searchbox", {name: "Search..."}).press("Enter");
await page.waitForLoadState("domcontentloaded");
console.log("✅ Search done, looking for Install button");

// Click Install on the matching card
const installBtn = page.getByRole("button", {name: "Activate"}).first();
await expect(installBtn).toBeVisible({timeout: 15_000});
await installBtn.click();

// Wait for installation — Odoo shows a loading spinner then reloads the webclient
console.log("✅ Waiting for installation to complete");
await page.waitForLoadState("domcontentloaded", {timeout: 180_000});
await page
.locator(".o_loading")
.waitFor({state: "hidden", timeout: 180_000})
.catch(() => {});
await expect(page.locator(".o_main_navbar")).toBeVisible({timeout: 180_000});
console.log("✅ Installation complete — webclient is back");
}

async function reloadBackend(page: Page) {
await page.goto("/odoo");
await page.waitForLoadState("domcontentloaded");
await expect(page.locator(".o_main_navbar")).toBeVisible({timeout: 60_000});
}

test.describe.serial("OpenSPP DCI Compliance", () => {
let page: Page;
// Uncaught errors thrown while the webclient mounts. A component whose setup()
// throws (#450) never reaches an error dialog: the OWL root dies first and the
// page stays blank, so this is the assertion that encodes that failure mode.
const pageErrors: string[] = [];

test.beforeAll(async ({browser}) => {
await resetStack();
page = await browser.newPage();
page.on("pageerror", (error) => pageErrors.push(error.message));
});

test.afterAll(async () => {
// page is only assigned after resetStack() succeeds — guard so a beforeAll
// failure/timeout reports its own real error instead of this masking it.
if (!page) return;
await page.close();
});

test.afterEach(async ({}, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus && !process.env.CI) {
console.log(
`❌ "${testInfo.title}" failed — pausing for investigation (set CI=1 to skip)`
);
await page.pause();
}
});

test("01 - login and install DCI Compliance", async () => {
await login(page);
await installApp(page, "spp_dci_compliance");
await page.screenshot({path: "reports/dci-post-install.png", fullPage: false});
});

test("02 - webclient renders and the systray item lists the enabled settings", async () => {
await reloadBackend(page);

// A broken service lookup in the component's setup() would surface here as a
// client error dialog and a webclient that never mounts.
await expect(page.locator(ERROR_DIALOG)).toHaveCount(0);
await expect(page.locator(".o_main_navbar .o_menu_systray")).toBeVisible();

// The module's post-install hook enables three insecure settings on purpose (it is
// a compliance test harness), so a fresh install must show all three.
const systrayItem = page.locator(SYSTRAY_ITEM);
await expect(systrayItem).toBeVisible({timeout: 30_000});
await expect(systrayItem.locator(".badge")).toHaveText("3");

await systrayItem.click();
await expect(page.getByText("Not safe for production!")).toBeVisible();
await expect(page.getByText("Allow Unsigned Requests")).toBeVisible();
await expect(page.getByText("Allow HTTP Callbacks")).toBeVisible();
await expect(page.getByText("Allow Internal Callback IPs")).toBeVisible();
await expect(page.getByText("Bypass Bearer Authentication")).toHaveCount(0);
await expect(page.locator(ERROR_DIALOG)).toHaveCount(0);
expect(pageErrors).toEqual([]);

await page.screenshot({
path: "reports/dci-systray-three-warnings.png",
fullPage: false,
});
await page.keyboard.press("Escape");
});

test("03 - turning a setting off removes it from the systray item", async () => {
// The System Parameters action is opened by its XML id; the Technical menu that
// normally leads to it needs developer mode, the action itself does not.
await page.goto("/odoo/action-base.ir_config_list_action");
await page.waitForLoadState("domcontentloaded");
const search = page.getByRole("searchbox", {name: "Search..."});
await expect(search).toBeVisible({timeout: 30_000});
await search.fill("dci.allow_unsigned_requests");
await search.press("Enter");
await page.waitForLoadState("domcontentloaded");

// The list is editable in place: clicking the Value cell turns it into a text
// field (a textarea, since ir.config_parameter.value is a Text field). Odoo puts
// the field name on the cell, which is the stable handle.
const row = page.getByRole("row", {name: /dci\.allow_unsigned_requests/});
const valueCell = row.locator("td[name='value']");
await valueCell.click();
const valueField = valueCell.getByRole("textbox");
await expect(valueField).toBeVisible({timeout: 15_000});
await valueField.fill("false");
await page.getByRole("button", {name: "Save", exact: true}).click();
await expect(valueCell).toHaveText("false");
console.log("✅ dci.allow_unsigned_requests = false saved");

await reloadBackend(page);

await expect(page.locator(ERROR_DIALOG)).toHaveCount(0);
const systrayItem = page.locator(SYSTRAY_ITEM);
await expect(systrayItem).toBeVisible({timeout: 30_000});
await expect(systrayItem.locator(".badge")).toHaveText("2");

await systrayItem.click();
await expect(page.getByText("Allow HTTP Callbacks")).toBeVisible();
await expect(page.getByText("Allow Internal Callback IPs")).toBeVisible();
await expect(page.getByText("Allow Unsigned Requests")).toHaveCount(0);
expect(pageErrors).toEqual([]);

await page.screenshot({
path: "reports/dci-systray-two-warnings.png",
fullPage: false,
});
});
});
66 changes: 66 additions & 0 deletions scripts/lint/check_odoo19.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
- group_expand signature: Detects old 3-parameter _read_group_* methods
- Legacy _sql_constraints: Detects the _sql_constraints class attribute, which
Odoo 19 ignores (constraints are silently never created); use models.Constraint
- Removed web services (--js): Detects useService("rpc") / useService("user") and
env.services.rpc / env.services.user in frontend JavaScript; both services are
gone and requesting them crashes the component at setup()

Features:
- Auto-fix support for Command API tuples (--fix)
Expand Down Expand Up @@ -90,6 +93,22 @@
r"def\s+(_read_group_\w+|_group_expand_\w+)\s*\(\s*self\s*,\s*\w+\s*,\s*\w+\s*,\s*\w+\s*\)"
)

# Web services that no longer exist. useService() raises
# "Service <name> is not available" for them at component setup(), so a
# component still requesting one crashes the moment it is mounted. Nothing
# fails at asset-build time because the useService import itself resolves.
REMOVED_WEB_SERVICES = {
"rpc": 'import {rpc} from "@web/core/network/rpc"; and call rpc(url, params) directly',
"user": 'import {user} from "@web/core/user";',
}
REMOVED_SERVICE_PATTERN = re.compile(
r"""(?<![\w$])useService\(\s*["'`](rpc|user)["'`]\s*[,)]""" # useService("rpc") / useService('user', ...)
r"""|\benv\.services\.(rpc|user)\b""" # this.env.services.rpc
)
# Comments are stripped before matching so a migration note quoting the old
# call does not trip the check. Newlines are kept so line numbers stay right.
JS_COMMENT_PATTERN = re.compile(r"//[^\n]*|/\*.*?\*/", re.DOTALL)


class CommandTupleVisitor(ast.NodeVisitor):
"""AST visitor to find Command API tuple patterns."""
Expand Down Expand Up @@ -328,6 +347,45 @@ def _is_view_file(self, path: Path) -> bool:
"""Check if XML file contains view definitions."""
return "views" in path.parts or "view" in path.name.lower() or "wizard" in path.parts

def check_js_file(self, file_path: str) -> list[Violation]:
"""Check a frontend JavaScript file for Odoo 19 issues."""
violations = []
path = Path(file_path)

if not path.exists() or path.suffix != ".js":
return violations

if self.config.should_ignore(file_path):
return violations

try:
with open(file_path, encoding="utf-8") as f:
content = f.read()
except Exception:
return violations

# Match the whole file (a formatter may wrap the call over several lines).
content = JS_COMMENT_PATTERN.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), content)

for match in REMOVED_SERVICE_PATTERN.finditer(content):
service = match.group(1) or match.group(2)
line_num = content.count("\n", 0, match.start()) + 1
violations.append(
Violation(
file_path=file_path,
line=line_num,
message=(
f"the {service!r} web service no longer exists; requesting it crashes the component at setup()"
),
rule_id="odoo19.removed_web_service",
severity=Severity.ERROR,
suggestion=f"Use {REMOVED_WEB_SERVICES[service]}",
doc_link="docs/principles/odoo19-compatibility.md#removed-web-services",
)
)

return violations

def _check_search_view_groups(self, file_path: str, root: ET.Element, lines: list[str]) -> list[Violation]:
"""Check for <group expand=...> or <group string=...> in search views."""
violations = []
Expand Down Expand Up @@ -566,6 +624,9 @@ def main():
# Check XML files
python check_odoo19.py --xml spp_programs/views/*.xml

# Check frontend JavaScript files
python check_odoo19.py --js spp_programs/static/src/**/*.js

# Auto-fix Command API tuples
python check_odoo19.py --fix spp_programs/models/*.py

Expand All @@ -577,6 +638,7 @@ def main():
add_common_args(parser)
parser.add_argument("files", nargs="*", help="Files to check")
parser.add_argument("--xml", action="store_true", help="Check XML files for search view issues")
parser.add_argument("--js", action="store_true", help="Check JavaScript files for removed web services")
parser.add_argument("--fix", action="store_true", help="Auto-fix Command API tuples")
parser.add_argument(
"--dry-run",
Expand All @@ -602,6 +664,8 @@ def main():
if args.xml:
files_to_check.extend(module_path.glob("views/*.xml"))
files_to_check.extend(module_path.glob("wizard/*.xml"))
elif args.js:
files_to_check.extend(module_path.glob("static/src/**/*.js"))
else:
files_to_check.extend(module_path.glob("models/*.py"))
files_to_check.extend(module_path.glob("wizard/*.py"))
Expand Down Expand Up @@ -640,6 +704,8 @@ def main():
file_str = str(file_path)
if args.xml or file_str.endswith(".xml"):
violations = checker.check_xml_file(file_str)
elif args.js or file_str.endswith(".js"):
violations = checker.check_js_file(file_str)
else:
violations = checker.check_python_file(file_str)
all_violations.extend(violations)
Expand Down
11 changes: 11 additions & 0 deletions spp_change_request_v2/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,17 @@ Before declaring a new CR type complete:
Changelog
=========

19.0.3.1.17
~~~~~~~~~~~

- fix(change_request): ``CRReviewPanel`` no longer requests the ``rpc``
web service in its setup. Odoo 19 has no such service and
``useService("rpc")`` throws the moment a component asking for it is
mounted; the handle was never used anyway. The panel is not wired into
any view today, so no screen was affected, but whoever mounts it next
would have hit the crash (#450). Whether the panel is wired in or
removed is tracked in #524

19.0.3.1.16
~~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_change_request_v2/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "OpenSPP Change Request V2",
"version": "19.0.3.1.16",
"version": "19.0.3.1.17",
"sequence": 50,
"category": "OpenSPP",
"summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention",
Expand Down
4 changes: 4 additions & 0 deletions spp_change_request_v2/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### 19.0.3.1.17

- fix(change_request): `CRReviewPanel` no longer requests the `rpc` web service in its setup. Odoo 19 has no such service and `useService("rpc")` throws the moment a component asking for it is mounted; the handle was never used anyway. The panel is not wired into any view today, so no screen was affected, but whoever mounts it next would have hit the crash (#450). Whether the panel is wired in or removed is tracked in #524

### 19.0.3.1.16

- fix(change_request): an Edit Individual change request can be opened again on a registrant that already holds a future date of birth. `spp.change.request.create` prefills the detail from the registrant and that prefill is a write, so the guard added in 19.0.3.1.15 refused the copied value and the request could not be created at all — closing the very path field staff use to correct the date. A birthdate the guard would refuse is now dropped from the prefill mapping rather than offered, so the field arrives empty and a valid date has to be entered. The rule itself lives in one place (`_is_future_birthdate` on the mixin), so what prefill declines to offer and what the constraint refuses cannot drift apart.
Expand Down
Loading
Loading